diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8bedc6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + check: + name: Test, typecheck and lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + # Pinned rather than `latest`: the lockfile format is version + # sensitive, and a new Bun landing upstream should not be able to + # break CI on an unrelated commit. Bump deliberately. + bun-version: "1.4.0" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Test + run: bun test + + - name: Typecheck + run: bun run typecheck + + - name: Lint + run: bun run lint diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..40f48bd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,161 @@ +# BetterDiscordBot + +Discord bot for the BetterDiscord community. Bun + TypeScript + discord.js 14, +Keyv over SQLite for storage. Private to BD — not publicly invitable. + +## Commands + +``` +bun run start # run the bot +bun run deploy # register slash commands (skips if unchanged; --force overrides) +bun run clear # deregister everything +bun run validate # check required environment variables +bun test # 96 tests +bun run typecheck # tsc --noEmit +bun run lint # eslint . +``` + +CI runs `bun test`, `bun run typecheck` and `bun run lint`. All three must pass. +Run them before committing — the type-aware lint rules apply to tests too, and +several genuine bugs have surfaced from `lint` rather than `tsc`. + +Bun is **pinned to 1.4.0** in `.github/workflows/ci.yml`. `bun.lock` is version +sensitive; if you change dependencies, regenerate the lockfile with the same Bun +version CI uses, or `--frozen-lockfile` fails there and not locally. + +## Layout + +``` +src/framework/ command / component / event plumbing (see its own README) +src/commands/ one file per slash command +src/components/ reusable message pieces +src/events/ one file per gateway listener (may export several) +src/util/ notices, modlog, colors, addons, names, stats, time, web +src/config.ts every Discord snowflake, env-overridable +tests/ bun test; helpers/ has the interaction and session stubs +``` + +`src/index.ts` builds the dispatcher at startup, so a malformed command or a +duplicate component namespace fails at boot rather than on first use. + +## Conventions + +**Plain object literals, never builders.** discord.js's data interfaces +(`ContainerComponentData`, `ActionRowData`, `RESTPostAPIChatInputApplicationCommandsJSONBody`…) +are fully typed and infer correctly. `SlashCommandBuilder` and `ContainerBuilder` +are not used anywhere. There was a JSX layer (`djsx/`) here until recently; it +was removed because TypeScript has one global `JSX.Element` type, so every +expression needed an `as` cast and the type checker stopped helping at exactly +the boundary where mistakes are expensive. Don't reintroduce it. + +**Components V2, not embeds.** Status messages go through +`src/util/notices.ts` (`success` / `info` / `warn` / `error` / `danger`), +moderation logs through `src/util/modlog.ts`. `/about` is the single deliberate +exception and says why in a comment: its stats are inline fields three to a row, +and V2 has no field grid. + +**Two interaction mechanisms, chosen by lifetime.** Read +`src/framework/README.md` before touching interaction code. Short version: + +- Must survive a restart or outlive the 15-minute token → `defineComponent`, + with state encoded in a typed custom id. +- Belongs to one invocation by one user → `runSession` (or `awaitModal`). + +Never hand-roll a collector. The ownership check, the timeout and the +disable-on-end pass live in `src/framework/session.ts` and nowhere else. + +**Config, not literals.** Snowflakes belong in `src/config.ts`, which allows an +env override per entry. `src/util/web.ts` keeps its release-channel ids — that +is upstream BetterDiscord website data, not deployment config. + +**Style.** 4 spaces, double quotes, `{noSpaces}` inside braces, semicolons. +Match the file you're in. + +## Footguns + +Every one of these caused a real, shipped bug in this repo. + +**`RegExp.prototype.test` with a `/g` flag is stateful.** It advances +`lastIndex` and resumes there next call, so a shared module-level regex returns +alternating answers for the same input. Use `/g` only with `matchAll` or +`String.match`. See `src/util/names.ts`. + +**A message cannot switch between embed mode and Components V2 mode after it is +created.** If a flow replies one way and updates the other, Discord rejects it. +Convert a whole flow at once or not at all. + +**`interaction.update()` works once.** A second call throws +`InteractionAlreadyReplied`. Use `editReply()` for subsequent edits — a +long-running handler that updates then updates again will silently never show +its result. + +**`editReply()` before any defer or reply throws.** Handlers that end in +`showModal()` can never defer, so their early exits must `reply()`. + +**Select menus need between 1 and 25 options**, and `setMaxValues(0)` is +invalid. Always guard the empty case before rendering a menu. + +**`MessageFlags.IsComponentsV2` widens to the whole `MessageFlags` enum** in an +unannotated object literal, which is not assignable to discord.js's narrower +per-method flag unions. Annotate with `ComponentMessage` from +`src/framework/ui.ts`, whose `flags` is `number` (assignable to a numeric enum). + +**Mixed-type component arrays need their element type pinned.** TypeScript +infers a union of object literals, fails to match a branch of the `components` +union, falls through to the snake_case API branch, and produces a 30-line +unreadable error. Use `row()` / `container()` / `text()` from +`src/framework/ui.ts`. These are annotations, not casts. + +**`TextInputComponentData` still requires `label`** even inside a `Label` +component, where the API ignores it. `src/components/tags.ts` absorbs this in +one helper; the payload deliberately omits it to match what ships. + +**Custom ids are capped at 100 characters.** `customId()` throws rather than +letting the API reject the message. Store a payload and reference it by key if +you need more. + +**`defineCommand` rejects extra properties**, so subcommand handlers must be +module-level functions, not methods called through `this`. + +**`string-similarity`'s `findBestMatch` throws on an empty candidate array.** +Guard before ranking. + +**Refresh caches by fetching into a local, then swapping.** Clearing and +stamping a timestamp before the request means one failure leaves an empty cache +that will not retry. See `ensureCache` in `src/util/addons.ts`. + +## Testing + +`bun test`. Two shared harnesses keep tests free of gateway or network setup: + +- `tests/helpers/interactions.ts` — stubs the type guards and reply methods the + dispatcher actually calls. +- `tests/helpers/session.ts` — stands in for the message component collector. + `press(action, {userId, values})` delivers a click; omit `values` for a + button, pass them for a select menu. + +The session harness waits for `runSession` to attach its collector before +emitting. A press issued immediately after starting a session lands before the +listener exists — that is the shape of a flake here, and it has twice turned out +to be the harness at fault rather than the code. Suspect the harness first. + +`tests/commands.test.ts` snapshots every deployed command payload against +`tests/fixtures/command-payloads.json`. Refactors should leave it untouched. +When a command change is intended: + +``` +bun run tests/fixtures/regenerate-payloads.ts +``` + +and review the resulting diff — that diff is the point of the fixture. + +`tests/regressions.test.ts` has one test per bug fixed during the refactor, each +naming the failure it guards against. Add to it when you fix something silent. + +## Deliberately left alone + +- `/about` keeps its `EmbedBuilder` (see above). +- The invite whitelist in `src/events/invitefilter.ts` is still a hardcoded + array with a `TODO`. Making it configurable is a feature decision. +- `src/commands/debug.tsx` is gitignored. If a local copy exists it predates the + djsx removal and will not load. diff --git a/Dockerfile b/Dockerfile index 3f36d09..16ab583 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,5 +20,6 @@ COPY --link . /app # Setup some default files RUN touch settings.sqlite3 -# Refresh commands when starting the bot +# Validate config, then deploy commands only if they changed since the last +# start (see scripts/deploy-commands.ts), then run the bot CMD ["sh", "-c", "bun run validate && bun run deploy && bun run start"] \ No newline at end of file diff --git a/bun.lock b/bun.lock index 6f8d865..e25b96c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 3, "configVersion": 0, "workspaces": { "": { @@ -16,16 +16,20 @@ "undici": "^7.16.0", }, "devDependencies": { + "@types/bun": "^1.4.0", "@types/string-similarity": "^4.0.2", - "@zerebos/eslint-config": "file:../../eslint-configs/packages/base", - "@zerebos/eslint-config-typescript": "file:../../eslint-configs/packages/typescript", + "@zerebos/eslint-config": "^1.0.3", + "@zerebos/eslint-config-typescript": "^1.1.1", "eslint": "^9.39.1", + "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", }, }, }, "overrides": { - "react": "./djsx/index.ts", + "sqlite3": { + "prebuild-install": "7.1.3", + }, }, "packages": { "@discordjs/builders": ["@discordjs/builders@1.13.0", "", { "dependencies": { "@discordjs/formatters": "^0.6.1", "@discordjs/util": "^1.1.1", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.31", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q=="], @@ -54,7 +58,7 @@ "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], - "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], @@ -74,12 +78,6 @@ "@keyv/sqlite": ["@keyv/sqlite@4.0.6", "", { "dependencies": { "sqlite3": "^5.1.7" }, "peerDependencies": { "keyv": "^5.5.3" } }, "sha512-xfUYps2HtxuQFsZlXv3qTs9p9mJMOSlNmCnd9R4UYxFlQJ1qLnKT+P0vjhQX9HBwnhKuhsinkmwTbooYFeFr4A=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@npmcli/fs": ["@npmcli/fs@1.1.1", "", { "dependencies": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" } }, "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ=="], "@npmcli/move-file": ["@npmcli/move-file@1.1.2", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg=="], @@ -92,6 +90,8 @@ "@tootallnate/once": ["@tootallnate/once@1.1.2", "", {}, "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -124,9 +124,9 @@ "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="], - "@zerebos/eslint-config": ["@zerebos/eslint-config@file:../../eslint-configs/packages/base", { "dependencies": { "@eslint/js": "^8.57.0", "globals": "^13.24.0" }, "peerDependencies": { "eslint": ">=8.0.0" } }], + "@zerebos/eslint-config": ["@zerebos/eslint-config@1.0.3", "", { "dependencies": { "@eslint/js": "^10.0.1", "globals": "^17.11.0" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-ZNKLp/7qano5Y28t0yUE8Yn1HfqRRgm7AiKcsXJ3FpUIAqszOdV/yBbN6BaquQ7m9cjKzBV3OvSUWxHMrkoCow=="], - "@zerebos/eslint-config-typescript": ["@zerebos/eslint-config-typescript@file:../../eslint-configs/packages/typescript", { "dependencies": { "typescript-eslint": "^8.34.0" }, "peerDependencies": { "eslint": ">=8.0.0" } }], + "@zerebos/eslint-config-typescript": ["@zerebos/eslint-config-typescript@1.1.1", "", { "dependencies": { "typescript-eslint": "^8.67.0" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-2hq9t3XzwvOkdCPwjZ95K+W0Zw3a0W0R+dic6ekFTptvrbM+uiKt6hkTjWA9h35MVLPfvq7UL6deNJ/OjtgQdA=="], "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], @@ -162,10 +162,10 @@ "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "cacache": ["cacache@15.3.0", "", { "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "glob": "^7.1.4", "infer-owner": "^1.0.4", "lru-cache": "^6.0.0", "minipass": "^3.1.1", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.2", "mkdirp": "^1.0.3", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^8.0.1", "tar": "^6.0.2", "unique-filename": "^1.1.1" } }, "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -238,22 +238,16 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -274,7 +268,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + "globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -322,8 +316,6 @@ "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], @@ -354,10 +346,6 @@ "make-fetch-happen": ["make-fetch-happen@9.1.0", "", { "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^4.0.1", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^6.0.0", "minipass": "^3.1.3", "minipass-collect": "^1.0.2", "minipass-fetch": "^1.3.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.2", "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" } }, "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg=="], - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], @@ -432,8 +420,6 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -442,12 +428,8 @@ "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -498,8 +480,6 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], @@ -510,9 +490,7 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.48.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A=="], @@ -556,7 +534,7 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@zerebos/eslint-config-typescript/typescript-eslint": ["typescript-eslint@8.34.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.34.1", "@typescript-eslint/parser": "8.34.1", "@typescript-eslint/utils": "8.34.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-XjS+b6Vg9oT1BaIUfkW3M3LvqZE++rbzAMEHuccCfO/YkP43ha6w3jTEMilQxMF92nVOYCcdjv1ZUhAa1D/0ow=="], + "@zerebos/eslint-config-typescript/typescript-eslint": ["typescript-eslint@8.68.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.68.0", "@typescript-eslint/parser": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/utils": "8.68.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ=="], "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -568,16 +546,12 @@ "eslint/@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "flat-cache/keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -598,11 +572,13 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.34.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/type-utils": "8.34.1", "@typescript-eslint/utils": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.34.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="], + + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.68.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.34.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/typescript-estree": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.68.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.68.0", "@typescript-eslint/tsconfig-utils": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.34.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/typescript-estree": "8.34.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.68.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ=="], "discord.js/@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], @@ -612,66 +588,70 @@ "discord.js/@discordjs/ws/discord-api-types": ["discord-api-types@0.38.12", "", {}, "sha512-vqkRM50N5Zc6OVckAqtSslbUEoXmpN4bd9xq2jkoK9fgO3KNRIOyMMQ7ipqjwjKuAgzWvU6G8bRIcYWaUe1sCA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1" } }, "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0" } }, "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.34.1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.34.1", "@typescript-eslint/utils": "8.34.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Tv7tCCr6e5m8hP4+xFugcrwTOucB8lshffJ6zf1mF1TbU67R+ntCc6DzLNKM+s/uzDyv8gLq7tufaAhIBYeV8g=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/utils": "8.68.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A=="], "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1" } }, "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0" } }, "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.1", "@typescript-eslint/tsconfig-utils": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1" } }, "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.68.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.68.0", "@typescript-eslint/types": "^8.68.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.68.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.1", "@typescript-eslint/tsconfig-utils": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.1", "@typescript-eslint/tsconfig-utils": "8.34.1", "@typescript-eslint/types": "8.34.1", "@typescript-eslint/visitor-keys": "8.34.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.1", "@typescript-eslint/types": "^8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0" } }, "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA=="], + + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.1", "@typescript-eslint/types": "^8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.68.0", "", {}, "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.1", "@typescript-eslint/types": "^8.34.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.34.1", "", {}, "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.68.0", "", { "dependencies": { "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@zerebos/eslint-config-typescript/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], } } diff --git a/bunfig.toml b/bunfig.toml deleted file mode 100644 index 831356b..0000000 --- a/bunfig.toml +++ /dev/null @@ -1,4 +0,0 @@ -jsx = "react-jsx" -jsxFactory = "createElement" -jsxFragment = "Fragment" -jsxImportSource = "@djsx" \ No newline at end of file diff --git a/djsx/ActionRow.tsx b/djsx/ActionRow.tsx deleted file mode 100644 index feb19a6..0000000 --- a/djsx/ActionRow.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {ComponentType, type ActionRowComponentData, type ActionRowData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type ActionRowProps = Omit, "type" | "components"> & {children: ActionRowComponentData | ActionRowComponentData[];}; - -export function ActionRow({children, ...props}: ActionRowProps): ActionRowData { - return { - type: ComponentType.ActionRow, - components: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/Button.tsx b/djsx/Button.tsx deleted file mode 100644 index f0c748c..0000000 --- a/djsx/Button.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {ComponentType, type ButtonComponentData, type InteractionButtonComponentData, type LinkButtonComponentData} from "discord.js"; -import {childrenToString} from "./utils"; - - -export {ButtonStyle} from "discord.js"; - -type Button = Omit | Omit; -export type ButtonProps = Button & {children: string;}; - -export function Button({children, ...props}: ButtonProps): ButtonComponentData { - return { - type: ComponentType.Button, - label: childrenToString("Button", children) ?? undefined, - ...props - }; -} \ No newline at end of file diff --git a/djsx/ChannelSelect.tsx b/djsx/ChannelSelect.tsx deleted file mode 100644 index 3b2571b..0000000 --- a/djsx/ChannelSelect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type ChannelSelectMenuComponentData} from "discord.js"; - - -export type ChannelSelectProps = Omit; - -export function ChannelSelect(props: ChannelSelectProps): ChannelSelectMenuComponentData { - return { - type: ComponentType.ChannelSelect, - ...props - }; -} \ No newline at end of file diff --git a/djsx/ComponentMessage.tsx b/djsx/ComponentMessage.tsx deleted file mode 100644 index afa50e2..0000000 --- a/djsx/ComponentMessage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {MessageFlags, type BaseMessageOptions, type InteractionEditReplyOptions, type InteractionReplyOptions} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type MessageOptions = InteractionReplyOptions & InteractionEditReplyOptions; -export type ComponentMessageProps = Omit & {children: Required["components"]; flags?: number;}; - -export function ComponentMessage({children, flags, ...props}: ComponentMessageProps): MessageOptions { - return { - flags: MessageFlags.IsComponentsV2 | (flags ?? 0), - components: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/Container.tsx b/djsx/Container.tsx deleted file mode 100644 index 4d58b68..0000000 --- a/djsx/Container.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {ComponentType, type ContainerComponentData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type ContainerProps = Omit & {children: ContainerComponentData["components"][0];}; - -export function Container({children, ...props}: ContainerProps): ContainerComponentData { - return { - type: ComponentType.Container, - components: childrenToArray(children), - ...props - } as ContainerComponentData; -} \ No newline at end of file diff --git a/djsx/File.tsx b/djsx/File.tsx deleted file mode 100644 index b6d8fcb..0000000 --- a/djsx/File.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {ComponentType, type FileComponentData, type UnfurledMediaItemData} from "discord.js"; -import {MediaItem} from "./MediaItem"; - - -export type FileProps = Omit & {filename: string;}; - -export function File({filename, id, spoiler}: FileProps): FileComponentData { - return { - type: ComponentType.File, - id, - spoiler, - file: () as UnfurledMediaItemData, - }; -} \ No newline at end of file diff --git a/djsx/FileUpload.tsx b/djsx/FileUpload.tsx deleted file mode 100644 index 97929c3..0000000 --- a/djsx/FileUpload.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type FileUploadModalData} from "discord.js"; - - -export type FileUploadProps = Omit; - -export function FileUpload({...props}: FileUploadProps): FileUploadModalData { - return { - type: ComponentType.FileUpload, - ...props - }; -} \ No newline at end of file diff --git a/djsx/JSX.d.ts b/djsx/JSX.d.ts deleted file mode 100644 index 9c41a31..0000000 --- a/djsx/JSX.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {BaseComponentData} from "discord.js"; - -declare namespace JSX { - interface ElementChildrenAttribute { - children: unknown; - } - - // type Element = any; - - type Element = - | BaseComponentData; - // | ReturnType; - // | ReturnType; -} \ No newline at end of file diff --git a/djsx/Label.tsx b/djsx/Label.tsx deleted file mode 100644 index 6eb04f1..0000000 --- a/djsx/Label.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {ComponentType, type LabelComponentData} from "discord.js"; -import {singleChild} from "./utils"; - - -export type LabelProps = Omit & {children: LabelComponentData["component"];}; - -export function ModalLabel({children, ...restProps}: LabelProps): LabelComponentData { - // console.log("ModalLabel called with children:", children); - return { - type: ComponentType.Label, - component: singleChild("ModalLabel", children) as LabelComponentData["component"], - ...restProps - }; -} \ No newline at end of file diff --git a/djsx/MediaGallery.tsx b/djsx/MediaGallery.tsx deleted file mode 100644 index 23699da..0000000 --- a/djsx/MediaGallery.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {ComponentType, type MediaGalleryComponentData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type MediaGalleryProps = Omit & {children: MediaGalleryComponentData["items"];}; - -export function MediaGallery({children, ...props}: MediaGalleryProps): MediaGalleryComponentData { - return { - type: ComponentType.MediaGallery, - items: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/MediaGalleryItem.tsx b/djsx/MediaGalleryItem.tsx deleted file mode 100644 index d84c087..0000000 --- a/djsx/MediaGalleryItem.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type {MediaGalleryItemData, UnfurledMediaItemData} from "discord.js"; -import {MediaItem} from "./MediaItem"; - - -export type MediaGalleryItemProps = Omit & {url: string;}; - -export function MediaGalleryItem({url, description, spoiler}: MediaGalleryItemProps): MediaGalleryItemData { - return { - media: as UnfurledMediaItemData, - description, - spoiler, - }; -} \ No newline at end of file diff --git a/djsx/MediaItem.tsx b/djsx/MediaItem.tsx deleted file mode 100644 index ff3c04b..0000000 --- a/djsx/MediaItem.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {UnfurledMediaItemData} from "discord.js"; - - -export type MediaItemProps = UnfurledMediaItemData; - -export function MediaItem(props: MediaItemProps): UnfurledMediaItemData { - return props; -} \ No newline at end of file diff --git a/djsx/MentionableSelect.tsx b/djsx/MentionableSelect.tsx deleted file mode 100644 index 9642bdc..0000000 --- a/djsx/MentionableSelect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type MentionableSelectMenuComponentData} from "discord.js"; - - -export type MentionableSelectProps = Omit; - -export function MentionableSelect(props: MentionableSelectProps): MentionableSelectMenuComponentData { - return { - type: ComponentType.MentionableSelect, - ...props - }; -} \ No newline at end of file diff --git a/djsx/Modal.tsx b/djsx/Modal.tsx deleted file mode 100644 index 4b0d1ac..0000000 --- a/djsx/Modal.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {type ModalComponentData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type ModalProps = Omit & {children: ModalComponentData["components"];}; - -export function Modal({children, ...props}: ModalProps): ModalComponentData { - return { - components: childrenToArray(children), - ...props - } as ModalComponentData; -} \ No newline at end of file diff --git a/djsx/RoleSelect.tsx b/djsx/RoleSelect.tsx deleted file mode 100644 index c72d3a9..0000000 --- a/djsx/RoleSelect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type RoleSelectMenuComponentData} from "discord.js"; - - -export type RoleSelectProps = Omit; - -export function RoleSelect(props: RoleSelectProps): RoleSelectMenuComponentData { - return { - type: ComponentType.RoleSelect, - ...props - }; -} \ No newline at end of file diff --git a/djsx/Section.tsx b/djsx/Section.tsx deleted file mode 100644 index 0fd0f53..0000000 --- a/djsx/Section.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {childrenToArray} from "./utils"; -import {ComponentType, type SectionComponentData} from "discord.js"; - - -export type SectionProps = Omit & {children: SectionComponentData["components"][0];}; - -export function Section({children, ...props}: SectionProps): SectionComponentData { - return { - type: ComponentType.Section, - components: childrenToArray(children), - ...props - }; -} \ No newline at end of file diff --git a/djsx/Separator.tsx b/djsx/Separator.tsx deleted file mode 100644 index b7e9475..0000000 --- a/djsx/Separator.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type SeparatorComponentData} from "discord.js"; - - -export type SeparatorProps = Omit; - -export function Separator(props: SeparatorProps): SeparatorComponentData { - return { - type: ComponentType.Separator, - ...props - }; -} \ No newline at end of file diff --git a/djsx/StringSelect.tsx b/djsx/StringSelect.tsx deleted file mode 100644 index 7a1b49e..0000000 --- a/djsx/StringSelect.tsx +++ /dev/null @@ -1,18 +0,0 @@ - - -import {ComponentType, type SelectMenuComponentOptionData, type StringSelectMenuComponentData} from "discord.js"; -import {childrenToArray} from "./utils"; - - -export type StringSelectProps = Omit & {children: StringSelectMenuComponentData["options"];}; -export function StringSelect({children, ...props}: StringSelectProps): StringSelectMenuComponentData { - return { - type: ComponentType.StringSelect, - options: childrenToArray(children), - ...props - }; -} - -export function StringOption(props: SelectMenuComponentOptionData): SelectMenuComponentOptionData { - return props; -} \ No newline at end of file diff --git a/djsx/TextDisplay.tsx b/djsx/TextDisplay.tsx deleted file mode 100644 index 8eea3ed..0000000 --- a/djsx/TextDisplay.tsx +++ /dev/null @@ -1,20 +0,0 @@ - - -import {ComponentType, type TextDisplayComponentData} from "discord.js"; -import {childrenToString} from "./utils"; - - -export type TextDisplayProps = Omit & {children: string | string[];}; - -export function TextDisplay({children, id}: TextDisplayProps): TextDisplayComponentData { - const content = childrenToString("TextDisplay", children)!; - if (!content) { - throw new Error("TextDisplay requires at least one child"); - } - - return { - type: ComponentType.TextDisplay, - content, - id, - }; -} \ No newline at end of file diff --git a/djsx/TextInput.tsx b/djsx/TextInput.tsx deleted file mode 100644 index dbbfb8e..0000000 --- a/djsx/TextInput.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {ComponentType, type TextInputComponentData} from "discord.js"; - - -export {TextInputStyle} from "discord.js"; -export type TextInputProps = Omit; - -export function TextInput(props: TextInputProps): Omit { - return { - type: ComponentType.TextInput, - ...props - }; -} \ No newline at end of file diff --git a/djsx/Thumbnail.tsx b/djsx/Thumbnail.tsx deleted file mode 100644 index 23ca191..0000000 --- a/djsx/Thumbnail.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {ComponentType, type ThumbnailComponentData} from "discord.js"; - - -export type ThumbnailProps = Omit & {url: string;}; - -export function Thumbnail({url, ...props}: ThumbnailProps): ThumbnailComponentData { - return { - type: ComponentType.Thumbnail, - media: {url}, - ...props - }; -} \ No newline at end of file diff --git a/djsx/UserSelect.tsx b/djsx/UserSelect.tsx deleted file mode 100644 index 1e68f1d..0000000 --- a/djsx/UserSelect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {ComponentType, type UserSelectMenuComponentData} from "discord.js"; - - -export type UserSelectProps = Omit; - -export function UserSelect(props: UserSelectProps): UserSelectMenuComponentData { - return { - type: ComponentType.UserSelect, - ...props - }; -} \ No newline at end of file diff --git a/djsx/commands/Command.tsx b/djsx/commands/Command.tsx deleted file mode 100644 index 9230bba..0000000 --- a/djsx/commands/Command.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import {ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, InteractionContextType, type APIApplicationCommandAttachmentOption, type APIApplicationCommandBasicOption, type APIApplicationCommandBooleanOption, type APIApplicationCommandChannelOption, type APIApplicationCommandIntegerOption, type APIApplicationCommandMentionableOption, type APIApplicationCommandNumberOption, type APIApplicationCommandOption, type APIApplicationCommandRoleOption, type APIApplicationCommandStringOption, type APIApplicationCommandSubcommandGroupOption, type APIApplicationCommandSubcommandOption, type APIApplicationCommandUserOption, type RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; - - -interface SlashCommandShortcuts { - guildContext?: boolean; - botDMContext?: boolean; - privateContext?: boolean; - guildInstall?: boolean; - userInstall?: boolean; -} - -export type SlashCommandProps = RESTPostAPIChatInputApplicationCommandsJSONBody & {children?: APIApplicationCommandOption | APIApplicationCommandOption[];} & SlashCommandShortcuts; - -export function SlashCommand({children, ...props}: SlashCommandProps): RESTPostAPIChatInputApplicationCommandsJSONBody { - const data = props; - - // Add options if any children were provided - if (Array.isArray(children)) data.options = children; - else if (children) data.options = [children]; - - // console.log(children); - - // Use shortcuts to set contexts - const contexts = []; - if (props.guildContext) contexts.push(InteractionContextType.Guild); - if (props.botDMContext) contexts.push(InteractionContextType.BotDM); - if (props.privateContext) contexts.push(InteractionContextType.PrivateChannel); - - // Use shortcuts to set integration_types - const integration_types = []; - if (props.guildInstall) integration_types.push(ApplicationIntegrationType.GuildInstall); - if (props.userInstall) integration_types.push(ApplicationIntegrationType.UserInstall); - - // Clean up the shortcut properties - delete data.guildContext; - delete data.botDMContext; - delete data.privateContext; - delete data.guildInstall; - delete data.userInstall; - - // Apply contexts and integration_types if any were set - if (contexts.length) data.contexts = contexts; - if (integration_types.length) data.integration_types = integration_types; - - return { - type: ApplicationCommandType.ChatInput, - ...data, - }; -} - - -export type SubcommandGroupProps = Omit & {children?: APIApplicationCommandSubcommandOption | APIApplicationCommandSubcommandOption[];}; - -export function SubcommandGroup({children, ...props}: SubcommandGroupProps): APIApplicationCommandSubcommandGroupOption { - const data = props; - - // Add options if any children were provided - if (Array.isArray(children)) data.options = children; - else if (children) data.options = [children]; - - return { - type: ApplicationCommandOptionType.SubcommandGroup, - ...data, - }; -} - - -export type SubcommandProps = Omit & {children?: APIApplicationCommandBasicOption | APIApplicationCommandBasicOption[];}; - -export function Subcommand({children, ...props}: SubcommandProps): APIApplicationCommandSubcommandOption { - const data = props; - - // Add options if any children were provided - if (Array.isArray(children)) data.options = children; - else if (children) data.options = [children]; - - return { - type: ApplicationCommandOptionType.Subcommand, - ...data, - }; -} - -/** - * Still left to implement: - * APIApplicationCommandAttachmentOption - * APIApplicationCommandBooleanOption - * APIApplicationCommandChannelOption - * APIApplicationCommandIntegerOption - * APIApplicationCommandMentionableOption - * APIApplicationCommandNumberOption - * APIApplicationCommandRoleOption - * APIApplicationCommandStringOption - * APIApplicationCommandUserOption - */ - - -export type AttachmentOptionProps = Omit; -export function AttachmentOption(props: AttachmentOptionProps): APIApplicationCommandAttachmentOption { - return { - type: ApplicationCommandOptionType.Attachment, - ...props, - }; -} - -export type BooleanOptionProps = Omit; -export function BooleanOption(props: BooleanOptionProps): APIApplicationCommandBooleanOption { - return { - type: ApplicationCommandOptionType.Boolean, - ...props, - }; -} - -export type ChannelOptionProps = Omit; -export function ChannelOption(props: ChannelOptionProps): APIApplicationCommandChannelOption { - return { - type: ApplicationCommandOptionType.Channel, - ...props, - }; -} - -export type IntegerOptionProps = Omit; -export function IntegerOption(props: IntegerOptionProps): APIApplicationCommandIntegerOption { - if (props.choices && props.choices.length) { - return { - type: ApplicationCommandOptionType.Integer, - ...props, - autocomplete: false - }; - } - - return { - type: ApplicationCommandOptionType.Integer, - ...props, - choices: undefined, - autocomplete: props.autocomplete - }; -} - -export type MentionableOptionProps = Omit; -export function MentionableOption(props: MentionableOptionProps): APIApplicationCommandMentionableOption { - return { - type: ApplicationCommandOptionType.Mentionable, - ...props, - }; -} - -export type NumberOptionProps = Omit; -export function NumberOption(props: NumberOptionProps): APIApplicationCommandNumberOption { - if (props.choices && props.choices.length) { - return { - type: ApplicationCommandOptionType.Number, - ...props, - autocomplete: false - }; - } - - return { - type: ApplicationCommandOptionType.Number, - ...props, - choices: undefined, - autocomplete: props.autocomplete - }; -} - -export type RoleOptionProps = Omit; -export function RoleOption(props: RoleOptionProps): APIApplicationCommandRoleOption { - return { - type: ApplicationCommandOptionType.Role, - ...props, - }; -} - -export type StringOptionProps = Omit; -export function StringOption(props: StringOptionProps): APIApplicationCommandStringOption { - if (props.choices && props.choices.length) { - return { - type: ApplicationCommandOptionType.String, - ...props, - autocomplete: false - }; - } - - return { - type: ApplicationCommandOptionType.String, - ...props, - choices: undefined, - autocomplete: props.autocomplete - }; -} - -export type UserOptionProps = Omit; -export function UserOption(props: UserOptionProps): APIApplicationCommandUserOption { - return { - type: ApplicationCommandOptionType.User, - ...props, - }; -} - - - - - - - - - - - - - - - - - - - - -export function test() { - return - - - - - - - - - - - - as RESTPostAPIChatInputApplicationCommandsJSONBody; -} - - -export function test2() { - return - - - - - - - - - as RESTPostAPIChatInputApplicationCommandsJSONBody; -} - -export function test3() { - return - - - - - - - - - - - - as RESTPostAPIChatInputApplicationCommandsJSONBody; -} - - -export function debug() { - return - - as RESTPostAPIChatInputApplicationCommandsJSONBody; -} - - -// const result = debug(); - -// console.log(JSON.stringify(result, null, 4)); \ No newline at end of file diff --git a/djsx/index.ts b/djsx/index.ts deleted file mode 100644 index 268204c..0000000 --- a/djsx/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export * from "./jsx-runtime"; -export * from "./ComponentMessage"; - -export * from "./ActionRow"; -export * from "./Button"; - - -export * from "./Container"; -export * from "./Section"; -export * from "./Separator"; - -export * from "./TextDisplay"; -export * from "./Label.tsx"; -export * from "./Modal.tsx"; - -export * from "./File"; -export * from "./FileUpload"; - -export * from "./MediaGallery"; -export * from "./MediaGalleryItem"; -export * from "./MediaItem"; -export * from "./Thumbnail.tsx"; - - -export * from "./ChannelSelect"; -export * from "./MentionableSelect"; -export * from "./RoleSelect"; -export * from "./StringSelect"; -export * from "./TextInput"; -export * from "./UserSelect"; diff --git a/djsx/jsx-dev-runtime.ts b/djsx/jsx-dev-runtime.ts deleted file mode 100644 index 9a56ded..0000000 --- a/djsx/jsx-dev-runtime.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * This system provides a minimal JSX runtime for creating component structures. - * It supports basic elements like line breaks and fragments, as well as function components. - * - * It was adapted from Venbot with permission https://github.com/Vencord/venbot/blob/main/LICENSE - */ - -export const Fragment = Symbol("ComponentsJsx.Fragment"); - -type FunctionComponent = (props: P) => R; - -export function createElement

(type: "br" | typeof Fragment | FunctionComponent, props: P, ...children: Array): R { - - // Normalize props and children - props ??= {} as P; - if (children.length > 0) props.children = children; - - switch (type) { - case "br": - return "\n" as R; - case Fragment: - return props.children as R; - } - - return type(props); -} - -export const jsx = createElement; -export const jsxs = createElement; -export const jsxDEV = createElement; - -// function logAndReturn(name: string) { -// return (type, props, ...children) => { -// console.log(name, "called"); -// console.log("createElement called with type:", type, "props:", props, "children:", children); -// return createElement(type, props, ...children); -// }; -// } - -// export const jsx = logAndReturn("jsx"); -// export const jsxs = logAndReturn("jsxs"); -// export const jsxDEV = logAndReturn("jsxDEV"); \ No newline at end of file diff --git a/djsx/jsx-runtime.ts b/djsx/jsx-runtime.ts deleted file mode 100644 index 9a56ded..0000000 --- a/djsx/jsx-runtime.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * This system provides a minimal JSX runtime for creating component structures. - * It supports basic elements like line breaks and fragments, as well as function components. - * - * It was adapted from Venbot with permission https://github.com/Vencord/venbot/blob/main/LICENSE - */ - -export const Fragment = Symbol("ComponentsJsx.Fragment"); - -type FunctionComponent = (props: P) => R; - -export function createElement

(type: "br" | typeof Fragment | FunctionComponent, props: P, ...children: Array): R { - - // Normalize props and children - props ??= {} as P; - if (children.length > 0) props.children = children; - - switch (type) { - case "br": - return "\n" as R; - case Fragment: - return props.children as R; - } - - return type(props); -} - -export const jsx = createElement; -export const jsxs = createElement; -export const jsxDEV = createElement; - -// function logAndReturn(name: string) { -// return (type, props, ...children) => { -// console.log(name, "called"); -// console.log("createElement called with type:", type, "props:", props, "children:", children); -// return createElement(type, props, ...children); -// }; -// } - -// export const jsx = logAndReturn("jsx"); -// export const jsxs = logAndReturn("jsxs"); -// export const jsxDEV = logAndReturn("jsxDEV"); \ No newline at end of file diff --git a/djsx/utils.ts b/djsx/utils.ts deleted file mode 100644 index 9086ad8..0000000 --- a/djsx/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -const isFalseOrNullish = (value: unknown) => value === false || value == null; -const isNotFalseOrNullish = (value: unknown) => !isFalseOrNullish(value); - -export function transformChildrenArray(children: Array): T[] { - return children.flat(Infinity).filter(isNotFalseOrNullish) as T[]; -} - -export function childrenToString(name: string, children: string | string[] | null): string | null { - if (Array.isArray(children)) { - return transformChildrenArray(children).join(""); - } - if (typeof children === "string") { - return children; - } - if (isFalseOrNullish(children)) { - return null; - } - throw new Error(`${name} children must be a string or an array of strings`); -} - -export function childrenToArray(children: T | T[]): T[] { - if (Array.isArray(children)) { - return transformChildrenArray(children); - } - if (isFalseOrNullish(children)) { - return []; - } - return [children]; -} - -export function singleChild(name: string, children: T | T[]): T { - if (!Array.isArray(children)) return children; - if (Array.isArray(children) && children.length !== 1) { - throw new Error(`${name} must have exactly one child`); - } - - return children[0]; -} - -export function hexToDecimal(hex: string): number { - return parseInt(hex.replace(/^#/, ""), 16); -} \ No newline at end of file diff --git a/djsx/widgets/Messages.tsx b/djsx/widgets/Messages.tsx deleted file mode 100644 index 07c6b3a..0000000 --- a/djsx/widgets/Messages.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import {MessageFlags, type ContainerComponentData, type InteractionEditReplyOptions, type InteractionReplyOptions} from "discord.js"; -import {Container} from "../Container"; -import {TextDisplay} from "../TextDisplay"; -import {hexToDecimal} from "../utils"; - - -export type MessageOptions = InteractionReplyOptions & InteractionEditReplyOptions; -export interface BasicMessageProps { - children: string | string[]; - flags?: number; - ephemeral?: boolean; - color?: string | number; -} - -export function Basic({children, flags, ephemeral, color}: BasicMessageProps): MessageOptions { - flags ??= 0; - if (ephemeral) flags |= MessageFlags.Ephemeral; - if (typeof color === "string") color = hexToDecimal(color); - - return { - flags: MessageFlags.IsComponentsV2 | flags, - components: [ - - {children} - as ContainerComponentData] - }; -} - - -export function Success(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} - -export function Info(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} - -export function Warn(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} - -export function Error(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} - -export function Danger(props: BasicMessageProps): MessageOptions { - return as MessageOptions; -} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 3f1b0eb..d2e3948 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,7 @@ import {defineConfig} from "eslint/config"; /** @type {import("@zerebos/eslint-config-typescript").ConfigArray} */ export default defineConfig( ...node, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- this file is not in the TS program, so the imported config array types resolve to `error` ...ts.configs.recommendedWithTypes, { rules: { @@ -14,12 +15,5 @@ export default defineConfig( }, { ignores: ["**/debug/**", "**/node_modules/**"] - }, - { - files: ["**/*.tsx"], - rules: { - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-argument": "off" - } } ); \ No newline at end of file diff --git a/package.json b/package.json index 9f09fbe..28574fa 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,23 @@ "main": "src/index.ts", "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "bun run --tsconfig-override tsconfig.bun.json --bun src/index.ts", - "deploy": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/deploy-commands.ts", - "clear": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/deploy-commands.ts --clear", - "validate": "bun run --tsconfig-override tsconfig.bun.json --bun scripts/validate-env.ts" + "test": "bun test", + "start": "bun run --bun src/index.ts", + "deploy": "bun run --bun scripts/deploy-commands.ts", + "clear": "bun run --bun scripts/deploy-commands.ts --clear", + "validate": "bun run --bun scripts/validate-env.ts", + "typecheck": "tsc --noEmit", + "lint": "eslint ." }, "author": "Zerebos", "license": "MIT", "devDependencies": { + "@types/bun": "^1.4.0", "@types/string-similarity": "^4.0.2", - "@zerebos/eslint-config": "file:../../eslint-configs/packages/base", - "@zerebos/eslint-config-typescript": "file:../../eslint-configs/packages/typescript", + "@zerebos/eslint-config": "^1.0.3", + "@zerebos/eslint-config-typescript": "^1.1.1", "eslint": "^9.39.1", + "typescript": "^5.9.3", "typescript-eslint": "^8.48.1" }, "dependencies": { @@ -34,7 +38,6 @@ "overrides": { "sqlite3": { "prebuild-install": "7.1.3" - }, - "react": "./djsx/index.ts" + } } } diff --git a/scripts/deploy-commands.ts b/scripts/deploy-commands.ts index fc0d70c..cf9098f 100644 --- a/scripts/deploy-commands.ts +++ b/scripts/deploy-commands.ts @@ -1,14 +1,28 @@ -import fs from "node:fs"; +import {createHash} from "node:crypto"; import path from "node:path"; -import {fileURLToPath, pathToFileURL} from "node:url"; +import {fileURLToPath} from "node:url"; import {REST, type RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; import {API} from "@discordjs/core"; -import type {CommandModule} from "../src/types"; +import {loadCommands} from "../src/framework"; +import {globalDB} from "../src/db"; import "dotenv/config"; // Check CLI arguments for clear flag const shouldClear = process.argv.includes("--clear") || process.argv.includes("-c"); +const shouldForce = process.argv.includes("--force") || process.argv.includes("-f"); + +/** + * The container runs this on every start, so an unguarded deploy meant a bulk + * overwrite of every global command on every restart — needless API traffic, + * and a rate-limit risk during a crash loop. The fingerprint covers what is + * sent and where, so a redeploy happens exactly when one of those changes. + */ +const FINGERPRINT_KEY = "deployedCommandsFingerprint"; + +const fingerprintOf = (global: unknown, guild: unknown) => createHash("sha256") + .update(JSON.stringify({global, guild, clientId: process.env.BOT_CLIENT_ID, guildId: process.env.BOT_GUILD_ID})) + .digest("hex"); // Setup file paths const __filename = fileURLToPath(import.meta.url); @@ -18,7 +32,10 @@ const __dirname = path.dirname(__filename); const rest = new REST().setToken(process.env.BOT_TOKEN!); const api = new API(rest); -async function setCommands(globalCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[], guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[]) { +/** Returns whether every part that was attempted succeeded. */ +async function setCommands(globalCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[], guildCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[]): Promise { + let ok = true; + // Deploy global commands try { console.log(`\n🚀 Started ${shouldClear ? "clearing" : "registering"} global application commands...`); @@ -27,6 +44,7 @@ async function setCommands(globalCommands: RESTPostAPIChatInputApplicationComman } catch (error) { console.error(`❌ Failed to ${shouldClear ? "clear" : "register"} global commands:`, error); + ok = false; } // Deploy guild commands (owner commands) @@ -38,6 +56,7 @@ async function setCommands(globalCommands: RESTPostAPIChatInputApplicationComman } catch (error) { console.error(`❌ Failed to ${shouldClear ? "clear" : "register"} guild commands:`, error); + ok = false; } } else if (!process.env.BOT_GUILD_ID) { @@ -45,42 +64,46 @@ async function setCommands(globalCommands: RESTPostAPIChatInputApplicationComman } console.log(`\n🎉 Command ${shouldClear ? "clearing" : "deployment"} complete!`); + return ok; } if (!shouldClear) { - const commands = []; - const ownerCommands = []; - const commandsPath = path.join(__dirname, "..", "src", "commands"); - const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith(".ts") || file.endsWith(".tsx")); - - for (const file of commandFiles) { - const filePath = path.join(commandsPath, file); - const commandModule = await import(pathToFileURL(filePath).href) as CommandModule | {default: CommandModule;}; - const command = ("default" in commandModule) ? commandModule.default : commandModule; - - if (!command.data) { - console.warn(`⚠️ Command ${file} has no data property, skipping...`); - continue; - } - - const commandData = "toJSON" in command.data ? command.data.toJSON() : command.data; + const commands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; + const ownerCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; + // Shared with the bot's own startup path, so what gets deployed is exactly + // what gets registered. Throws on a malformed module instead of skipping it. + for (const command of await loadCommands(path.join(__dirname, "..", "src", "commands"))) { // Separate owner commands to "privileged" guild - if (command.owner) { - ownerCommands.push(commandData); - console.log(`🔒 Owner command: ${commandData.name}`); + if (command.ownerOnly) { + ownerCommands.push(command.data); + console.log(`🔒 Owner command: ${command.name}`); } else { - commands.push(commandData); - console.log(`🌐 Global command: ${commandData.name}`); - if (commandData.integration_types?.includes(1)) console.log(` 📱 User-installable`); + commands.push(command.data); + console.log(`🌐 Global command: ${command.name}`); + if (command.data.integration_types?.includes(1)) console.log(` 📱 User-installable`); } } console.log(`📁 Loaded ${commands.length} global commands and ${ownerCommands.length} owner commands`); - await setCommands(commands, ownerCommands); + + const fingerprint = fingerprintOf(commands, ownerCommands); + const deployed = await globalDB.get(FINGERPRINT_KEY); + + if (deployed === fingerprint && !shouldForce) { + console.log("\n⏭️ Commands are unchanged since the last deploy - skipping. Use --force to deploy anyway."); + } + else { + // Only remember the fingerprint if everything actually landed, so a + // failed deploy retries on the next start instead of being skipped. + const ok = await setCommands(commands, ownerCommands); + if (ok) await globalDB.set(FINGERPRINT_KEY, fingerprint); + else console.log("⚠️ Not recording the fingerprint; the next run will retry."); + } } else { console.log("🗑️ Clearing all commands..."); await setCommands([], []); + await globalDB.delete(FINGERPRINT_KEY); } diff --git a/src/commands/about.ts b/src/commands/about.ts index 2060825..5ea422e 100644 --- a/src/commands/about.ts +++ b/src/commands/about.ts @@ -1,29 +1,36 @@ import childProcess from "child_process"; import {promisify} from "util"; -import {SlashCommandBuilder, EmbedBuilder, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, ApplicationIntegrationType, InteractionContextType} from "discord.js"; +import {ApplicationCommandType, ApplicationIntegrationType, ChannelType, EmbedBuilder, InteractionContextType} from "discord.js"; +import {defineCommand} from "../framework"; import type {CommandStats} from "../types"; import {statsDB} from "../db"; import {humanReadableUptime} from "../util/time"; const exec = promisify(childProcess.exec); -const inviteLink = `https://discord.com/oauth2/authorize?client_id=${process.env.BOT_CLIENT_ID}&permissions=${process.env.BOT_PERMISSIONS || "0"}&scope=bot%20applications.commands`; -const userInviteLink = `https://discord.com/oauth2/authorize?client_id=${process.env.BOT_CLIENT_ID}&integration_type=1&scope=applications.commands`; -export default { - data: new SlashCommandBuilder() - .setName("about") - .setDescription("Gives some information about the bot") - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) - .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel), +export const command = defineCommand({ + data: { + type: ApplicationCommandType.ChatInput, + name: "about", + description: "Gives some information about the bot", + integration_types: [ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall], + contexts: [InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel] + }, - async execute(interaction: ChatInputCommandInteraction) { + /** + * The one place that still uses an embed rather than a Components V2 + * container. The stats below are laid out as inline fields, three to a row; + * V2 has no field grid and faking one with padded text does not survive + * different client widths. Everything else in the bot sends V2. + */ + async execute(interaction) { await interaction.deferReply(); const aboutEmbed = new EmbedBuilder(); aboutEmbed.setColor("Blue"); aboutEmbed.setAuthor({name: interaction.client.user.username, iconURL: interaction.client.user.displayAvatarURL()}); - //aboutEmbed.setDescription("**🆕 Now user-installable!** Add to your account for DM access and cross-server profiles."); + // aboutEmbed.setDescription("**🆕 Now user-installable!** Add to your account for DM access and cross-server profiles."); const owner = await interaction.client.users.fetch(process.env.BOT_OWNER_ID!); if (owner) aboutEmbed.setFooter({text: `Created by @${owner.username}`, iconURL: owner.displayAvatarURL()}); @@ -107,14 +114,8 @@ export default { addField(`Commands Run`, commandsRun, true); addField(`Uptime`, humanReadableUptime(now - interaction.client.readyAt.valueOf()), true); - await interaction.editReply({ - embeds: [aboutEmbed], - /*components: [ - new ActionRowBuilder().addComponents( - new ButtonBuilder().setLabel(`Invite ${interaction.client.user.username}`).setStyle(ButtonStyle.Link).setURL(inviteLink).setEmoji("🔗"), - new ButtonBuilder().setLabel("Add to Account").setStyle(ButtonStyle.Link).setURL(userInviteLink).setEmoji("📱") - ) - ]*/ - }); + // The invite / "Add to Account" link buttons were parked in 335cf56 along + // with their OAuth URL constants; restore them from there if wanted. + await interaction.editReply({embeds: [aboutEmbed]}); }, -}; +}); diff --git a/src/commands/addons.ts b/src/commands/addons.ts index 7ed3bd1..ffb6d98 100644 --- a/src/commands/addons.ts +++ b/src/commands/addons.ts @@ -1,183 +1,214 @@ -import {ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, InteractionContextType, MessageFlags, SlashCommandBuilder, type AutocompleteFocusedOption} from "discord.js"; -import Messages from "../util/messages"; +import {ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, InteractionContextType, MessageFlags, type AutocompleteFocusedOption} from "discord.js"; +import {defineCommand} from "../framework"; +import * as notices from "../util/notices"; import type {BdWebAddon, BdWebTag} from "../types"; import Similarity from "string-similarity"; import Web from "../util/web"; -import Paginator from "../paginator"; +import {paginate} from "../paginator"; import {cache, ensureCache, createAddonComponent, paginateAddonPages, sortAddons, createAddonList} from "../util/addons"; const TAG_CHOICES = [...Web.store.tags.plugin, ...Web.store.tags.theme]; -export default { - data: new SlashCommandBuilder() - .setName("addons") - .setDescription("Commands for addons.") - .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel) - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) - .addSubcommand(c => c.setName("updated").setDescription("Shows the most recently updated addons")) - .addSubcommand(c => c.setName("newest").setDescription("Shows the newest added addons")) - .addSubcommand(c => c.setName("top").setDescription("Shows the most liked addons")) - .addSubcommand(c => c.setName("popular").setDescription("Shows the most downloaded addons")) - .addSubcommand(c => c.setName("random").setDescription("Shows a random addon")) - .addSubcommand(c => c.setName("search").setDescription("Searches for an addon by name") - .addStringOption(opt => opt.setName("name").setDescription("Name of the addon to find").setRequired(true).setAutocomplete(true)) - ) - .addSubcommand(c => c.setName("info").setDescription("Gets information about an addon") - .addStringOption(opt => opt.setName("name").setDescription("Name of the addon to get info about").setRequired(true).setAutocomplete(true)) - ) - .addSubcommand(c => c.setName("browse").setDescription("Browse addons in an interactive way") - .addStringOption(opt => - opt.setName("tag").setDescription("tag to browse").setRequired(false).setAutocomplete(true) - ) - .addStringOption(opt => - opt.setName("type").setDescription("type to browse").setRequired(false).addChoices( - {name: "Plugin", value: "plugin"}, - {name: "Theme", value: "theme"}, - ) - ) - .addStringOption(opt => - opt.setName("sort").setDescription("sort method").setRequired(false).addChoices( - {name: "Newest", value: "initial_release_date"}, - {name: "Last Updated", value: "latest_release_date"}, - {name: "Most Liked", value: "likes"}, - {name: "Popular", value: "downloads"}, - ) - ) - ), - - /** +/** * Main function for addons command */ - async execute(interaction: ChatInputCommandInteraction<"cached">) { - await interaction.deferReply(); - await ensureCache(); - const command = interaction.options.getSubcommand(); - if (command === "search") return await this.search(interaction); - if (command === "browse") return await this.browse(interaction); - if (command === "updated") return await this.top10(interaction, "latest_release_date"); - if (command === "newest") return await this.top10(interaction, "initial_release_date"); - if (command === "top") return await this.top10(interaction, "likes"); - if (command === "popular") return await this.top10(interaction, "downloads"); - if (command === "random") return await this.random(interaction); - if (command === "info") return await this.info(interaction); - - return await interaction.editReply(Messages.error("This command is not yet implemented.")); - }, - - /** - * Complex commands - */ - - async browse(interaction: ChatInputCommandInteraction<"cached">) { - const tag = interaction.options.getString("tag"); - const type = interaction.options.getString("type"); - const sort = interaction.options.getString("sort") || "downloads"; - - const filteredAddons = Array.from(cache).filter(addon => { - if (tag && !addon.tags.includes(tag as BdWebTag)) return false; - if (type && addon.type !== type) return false; - return true; - }); - - // No need to continue if there are no results - if (filteredAddons.length === 0) return await interaction.editReply(Messages.error("No addons found with the specified criteria.")); - - sortAddons(filteredAddons, sort as "likes" | "downloads" | "initial_release_date" | "latest_release_date"); - - const title: string[] = []; - title.push(type ? type.charAt(0).toUpperCase() + type.slice(1) + "s" : "Addons"); - if (tag) title.push(`with tag \`${tag}\``); - title.push(`sorted by ${sort.replace(/_/g, " ")}`); - const paginator = new Paginator({ - interaction, - items: filteredAddons, - itemsPerPage: 3, - renderPage: addons => createAddonList(`${title.join(" ")}`, addons), - }); - await paginator.paginate(); - }, - - async search(interaction: ChatInputCommandInteraction<"cached">) { - const name = interaction.options.getString("name", true).toLowerCase(); - let results: BdWebAddon[] = []; - for (const addon of cache) { - if (addon.name.toLowerCase().includes(name) || (addon.description?.toLowerCase().includes(name))) { - results.push(addon); - } +async function browse(interaction: ChatInputCommandInteraction) { + const tag = interaction.options.getString("tag"); + const type = interaction.options.getString("type"); + const sort = interaction.options.getString("sort") || "downloads"; + + const filteredAddons = Array.from(cache).filter(addon => { + if (tag && !addon.tags.includes(tag as BdWebTag)) return false; + if (type && addon.type !== type) return false; + return true; + }); + + // No need to continue if there are no results + if (filteredAddons.length === 0) return await interaction.editReply(notices.error("No addons found with the specified criteria.")); + + sortAddons(filteredAddons, sort as "likes" | "downloads" | "initial_release_date" | "latest_release_date"); + + const title: string[] = []; + title.push(type ? type.charAt(0).toUpperCase() + type.slice(1) + "s" : "Addons"); + if (tag) title.push(`with tag \`${tag}\``); + title.push(`sorted by ${sort.replace(/_/g, " ")}`); + + await paginate({ + interaction, + items: filteredAddons, + perPage: 3, + renderPage: addons => createAddonList(title.join(" "), addons), + }); +} + +async function search(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true).toLowerCase(); + const results: BdWebAddon[] = []; + for (const addon of cache) { + if (addon.name.toLowerCase().includes(name) || (addon.description?.toLowerCase().includes(name))) { + results.push(addon); } - - results = Similarity.findBestMatch(name, results.map(a => a.name)).ratings - .sort((a, b) => b.rating - a.rating) - .slice(0, 10) - .map(rating => results.find(a => a.name === rating.target)!) - .filter(a => !!a); - - await paginateAddonPages(interaction, results); - }, - - /** - * Simple commands - */ - - async top10(interaction: ChatInputCommandInteraction<"cached">, sortBy: "likes" | "downloads" | "initial_release_date" | "latest_release_date") { - await paginateAddonPages(interaction, sortAddons(Array.from(cache), sortBy).slice(0, 10)); - }, - - async random(interaction: ChatInputCommandInteraction<"cached">) { - const addonsArray = Array.from(cache); - const randomAddon = addonsArray[Math.floor(Math.random() * addonsArray.length)]; - return await interaction.editReply({components: [createAddonComponent(randomAddon)], flags: MessageFlags.IsComponentsV2}); - }, - - async info(interaction: ChatInputCommandInteraction<"cached">) { - const name = interaction.options.getString("name", true).toLowerCase(); - const addon = Array.from(cache).find(a => a.name.toLowerCase() === name); - if (!addon) return await interaction.editReply(Messages.error("No addon found with that name.")); - return await interaction.editReply({components: [createAddonComponent(addon)], flags: MessageFlags.IsComponentsV2}); + } + + // findBestMatch throws on an empty candidate list, so a search that matched + // nothing used to surface as the dispatcher's generic error. + if (!results.length) { + return await interaction.editReply(notices.info(`No addons matched \`${name}\`.`)); + } + + const ranked = Similarity.findBestMatch(name, results.map(addon => addon.name)).ratings + .sort((a, b) => b.rating - a.rating) + .slice(0, 10) + .map(rating => results.find(addon => addon.name === rating.target)) + .filter((addon): addon is BdWebAddon => addon !== undefined); + + await paginateAddonPages(interaction, ranked, `No addons matched \`${name}\`.`); +} + + +async function top10(interaction: ChatInputCommandInteraction, sortBy: "likes" | "downloads" | "initial_release_date" | "latest_release_date") { + await paginateAddonPages(interaction, sortAddons(Array.from(cache), sortBy).slice(0, 10), "The addon store is empty right now. Please try again shortly."); +} + +async function random(interaction: ChatInputCommandInteraction) { + const addonsArray = Array.from(cache); + // An empty cache would otherwise index past the end and render `undefined`. + if (!addonsArray.length) { + return await interaction.editReply(notices.info("The addon store is empty right now. Please try again shortly.")); + } + + const randomAddon = addonsArray[Math.floor(Math.random() * addonsArray.length)]; + return await interaction.editReply({components: [createAddonComponent(randomAddon)], flags: MessageFlags.IsComponentsV2}); +} + +async function info(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true).toLowerCase(); + const addon = Array.from(cache).find(a => a.name.toLowerCase() === name); + if (!addon) return await interaction.editReply(notices.error("No addon found with that name.")); + return await interaction.editReply({components: [createAddonComponent(addon)], flags: MessageFlags.IsComponentsV2}); +} + + + +async function autocomplete(interaction: AutocompleteInteraction) { + await ensureCache(); + const focusedValue = interaction.options.getFocused(true); + if (focusedValue.name === "name") return await autocompleteName(interaction, focusedValue); + if (focusedValue.name === "tag") return await autocompleteTag(interaction, focusedValue); +} + +async function autocompleteName(interaction: AutocompleteInteraction, focused: AutocompleteFocusedOption) { + const names = Array.from(cache).map(addon => addon.name); + if (focused.value.length === 0) { + const results = names.slice(0, 25).map(name => ({name, value: name})); + return await interaction.respond(results); + } + + const results = Similarity.findBestMatch(focused.value, names).ratings + .sort((a, b) => b.rating - a.rating) + .slice(0, 25) + .map(rating => ({name: rating.target, value: rating.target})); + + await interaction.respond(results); +} + +async function autocompleteTag(interaction: AutocompleteInteraction, focused: AutocompleteFocusedOption) { + if (focused.value.length === 0) { + const results = TAG_CHOICES.slice(0, 25).map(name => ({name, value: name})); + return await interaction.respond(results); + } + + const results = Similarity.findBestMatch(focused.value, TAG_CHOICES).ratings + .sort((a, b) => b.rating - a.rating) + .slice(0, 25) + .map(rating => ({name: rating.target, value: rating.target})); + + await interaction.respond(results); +} + + +const nameOpt = (description: string) => ({ + type: ApplicationCommandOptionType.String as const, + name: "name", + description, + required: true, + autocomplete: true +}); + +const simple = (name: string, description: string) => ({ + type: ApplicationCommandOptionType.Subcommand as const, + name, + description +}); + + +export const command = defineCommand({ + data: { + type: ApplicationCommandType.ChatInput, + name: "addons", + description: "Commands for addons.", + contexts: [InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel], + integration_types: [ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall], + options: [ + simple("updated", "Shows the most recently updated addons"), + simple("newest", "Shows the newest added addons"), + simple("top", "Shows the most liked addons"), + simple("popular", "Shows the most downloaded addons"), + simple("random", "Shows a random addon"), + {...simple("search", "Searches for an addon by name"), options: [nameOpt("Name of the addon to find")]}, + {...simple("info", "Gets information about an addon"), options: [nameOpt("Name of the addon to get info about")]}, + { + ...simple("browse", "Browse addons in an interactive way"), + options: [ + { + type: ApplicationCommandOptionType.String as const, + name: "tag", + description: "tag to browse", + required: false, + autocomplete: true + }, + { + type: ApplicationCommandOptionType.String as const, + name: "type", + description: "type to browse", + required: false, + choices: [{name: "Plugin", value: "plugin"}, {name: "Theme", value: "theme"}] + }, + { + type: ApplicationCommandOptionType.String as const, + name: "sort", + description: "sort method", + required: false, + choices: [ + {name: "Newest", value: "initial_release_date"}, + {name: "Last Updated", value: "latest_release_date"}, + {name: "Most Liked", value: "likes"}, + {name: "Popular", value: "downloads"} + ] + } + ] + } + ] }, - - /** - * Autocomplete handlers for tags and addon names - */ - - async autocomplete(interaction: AutocompleteInteraction<"cached">) { + async execute(interaction) { + await interaction.deferReply(); await ensureCache(); - const focusedValue = interaction.options.getFocused(true); - if (focusedValue.name === "name") return await this.autocompleteName(interaction, focusedValue); - if (focusedValue.name === "tag") return await this.autocompleteTag(interaction, focusedValue); - }, - - async autocompleteName(interaction: AutocompleteInteraction<"cached">, focused: AutocompleteFocusedOption) { - const names = Array.from(cache).map(addon => addon.name); - if (focused.value.length === 0) { - const results = names.slice(0, 25).map(name => ({name, value: name})); - return await interaction.respond(results); - } - - const results = Similarity.findBestMatch(focused.value, names).ratings - .sort((a, b) => b.rating - a.rating) - .slice(0, 25) - .map(rating => ({name: rating.target, value: rating.target})); - - await interaction.respond(results); - }, - - async autocompleteTag(interaction: AutocompleteInteraction<"cached">, focused: AutocompleteFocusedOption) { - if (focused.value.length === 0) { - const results = TAG_CHOICES.slice(0, 25).map(name => ({name, value: name})); - return await interaction.respond(results); - } - - const results = Similarity.findBestMatch(focused.value, TAG_CHOICES).ratings - .sort((a, b) => b.rating - a.rating) - .slice(0, 25) - .map(rating => ({name: rating.target, value: rating.target})); - - await interaction.respond(results); + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "search") return await search(interaction); + if (subcommand === "browse") return await browse(interaction); + if (subcommand === "updated") return await top10(interaction, "latest_release_date"); + if (subcommand === "newest") return await top10(interaction, "initial_release_date"); + if (subcommand === "top") return await top10(interaction, "likes"); + if (subcommand === "popular") return await top10(interaction, "downloads"); + if (subcommand === "random") return await random(interaction); + if (subcommand === "info") return await info(interaction); + + return await interaction.editReply(notices.error("This command is not yet implemented.")); }, -}; + autocomplete +}); diff --git a/src/commands/botadmin.ts b/src/commands/botadmin.ts index 8b86fcb..6670b3c 100644 --- a/src/commands/botadmin.ts +++ b/src/commands/botadmin.ts @@ -1,111 +1,128 @@ -import {ActionRowBuilder, ChannelType, ChatInputCommandInteraction, ModalBuilder, SlashCommandBuilder, TextChannel, TextInputBuilder, TextInputStyle, type PartialTextBasedChannelFields} from "discord.js"; -import Messages from "../util/messages"; +import { + ApplicationCommandOptionType, ApplicationCommandType, ChannelType, ChatInputCommandInteraction, + ComponentType, TextInputStyle, + type ModalComponentData, type PartialTextBasedChannelFields +} from "discord.js"; +import {awaitModal, defineCommand} from "../framework"; import {globalDB} from "../db"; - - - -export default { - owner: true, - data: new SlashCommandBuilder() - .setName("botadmin") - .setDescription("Global settings for the bot during runtime.") - .addSubcommandGroup(group => - group.setName("send").setDescription("Sends messages to different locations") - .addSubcommand(c => - c.setName("user").setDescription("Sends a DM to the specified user.") - .addUserOption(opt => - opt.setName("user").setDescription("User to DM.").setRequired(true) - ) - ) - .addSubcommand(c => - c.setName("channel").setDescription("Sends a message to the specified channel.") - .addChannelOption(opt => - opt.setName("channel").setDescription("Channel to send a message.").setRequired(true) - .addChannelTypes(ChannelType.GuildText) - ) - ) - ) - .addSubcommand( - c => c.setName("forwarding").setDescription("Sets up DM forwarding to a user.") - .addUserOption(opt => - opt.setName("user").setDescription("Who to forward DMs to?").setRequired(false) - ) - ) - .addSubcommand(c => c.setName("quit").setDescription("Exits the bot gracefully.")), - - - async execute(interaction: ChatInputCommandInteraction) { - if (interaction.user.id !== process.env.BOT_OWNER_ID) return await interaction.reply(Messages.error("Sorry this command is only usable by the owner!", {ephemeral: true})); - - const group = interaction.options.getSubcommandGroup(); - const command = interaction.options.getSubcommand(); - if (group === "send") { - if (command === "channel") return await this.channel(interaction); - if (command === "user") return await this.user(interaction); +import * as notices from "../util/notices"; + + +const sendModal: ModalComponentData = { + customId: "botadmin-send", + title: "Message To Send", + components: [{ + type: ComponentType.Label, + label: "Message", + component: { + type: ComponentType.TextInput, + customId: "message", + label: "Message", + style: TextInputStyle.Paragraph, + required: true, + maxLength: 2000, + value: "" } - if (command === "forwarding") return await this.forwarding(interaction); - if (command === "quit") return await this.quit(interaction); - }, - - - async channel(interaction: ChatInputCommandInteraction) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return await this.send(interaction, interaction.options.getChannel("channel", true) as TextChannel); - }, - - - async user(interaction: ChatInputCommandInteraction) { - return await this.send(interaction, interaction.options.getUser("user", true)); - }, - - - async send(interaction: ChatInputCommandInteraction, target: PartialTextBasedChannelFields) { - const modal = new ModalBuilder().setTitle("Message To Send").setCustomId("botadmin-send") - .addComponents( - new ActionRowBuilder() - .addComponents( - new TextInputBuilder().setCustomId("message").setLabel("Message") - .setStyle(TextInputStyle.Paragraph).setRequired(true) - .setMaxLength(2000).setValue("") - ) - ); - + }] +}; - await interaction.showModal(modal); - try { - const modalInteraction = await interaction.awaitModalSubmit({time: 60_000}); - const message = modalInteraction.fields.getTextInputValue("message"); - try { - await target.send(message); - await modalInteraction.reply(Messages.success("Message sent successfully!", {ephemeral: true})); - } - catch { - await modalInteraction.reply(Messages.error("Could not send message!", {ephemeral: true})); +async function send(interaction: ChatInputCommandInteraction, target: PartialTextBasedChannelFields) { + const submission = await awaitModal(interaction, sendModal, ["message"], {time: 60_000}); + if (!submission) return await interaction.followUp(notices.error("Modal submission timed out!", {ephemeral: true})); + + try { + await target.send(submission.values.message); + await submission.submission.reply(notices.success("Message sent successfully!", {ephemeral: true})); + } + catch { + await submission.submission.reply(notices.error("Could not send message!", {ephemeral: true})); + } +} + + +async function forwarding(interaction: ChatInputCommandInteraction) { + const targetUser = interaction.options.getUser("user"); + if (targetUser) await globalDB.set("forwarding", targetUser.id); + else await globalDB.delete("forwarding"); + await interaction.reply(notices.success(targetUser ? `Now forwarding DMs to <@${targetUser.id}>!` : "No longer forwarding DMs!", {ephemeral: true})); +} + + +async function quit(interaction: ChatInputCommandInteraction) { + await interaction.reply(notices.info("Bot shutting down...", {ephemeral: true})); + await interaction.client.destroy(); + process.exit(0); +} + + +export const command = defineCommand({ + ownerOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "botadmin", + description: "Global settings for the bot during runtime.", + options: [ + { + type: ApplicationCommandOptionType.SubcommandGroup, + name: "send", + description: "Sends messages to different locations", + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: "user", + description: "Sends a DM to the specified user.", + options: [{ + type: ApplicationCommandOptionType.User, + name: "user", + description: "User to DM.", + required: true + }] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "channel", + description: "Sends a message to the specified channel.", + options: [{ + type: ApplicationCommandOptionType.Channel, + name: "channel", + description: "Channel to send a message.", + required: true, + channel_types: [ChannelType.GuildText] + }] + } + ] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "forwarding", + description: "Sets up DM forwarding to a user.", + options: [{ + type: ApplicationCommandOptionType.User, + name: "user", + description: "Who to forward DMs to?", + required: false + }] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "quit", + description: "Exits the bot gracefully." } - } - catch { - await interaction.followUp(Messages.error("Modal submission timed out!", {ephemeral: true})); - } - }, - - /** - * This is just here to satisfy the event requirement I imposed on myself - */ - async modal() {}, - - - async forwarding(interaction: ChatInputCommandInteraction) { - const targetUser = interaction.options.getUser("user"); - if (targetUser) await globalDB.set("forwarding", targetUser.id); - else await globalDB.delete("forwarding"); - await interaction.reply(Messages.success(targetUser ? `Now forwarding DMs to <@${targetUser.id}>!` : "No longer forwarding DMs!", {ephemeral: true})); + ] }, + // The owner check is the dispatcher's job now; `ownerOnly` above also keeps + // this command deployed to the private guild rather than globally. + async execute(interaction) { + const group = interaction.options.getSubcommandGroup(); + const subcommand = interaction.options.getSubcommand(); - async quit(interaction: ChatInputCommandInteraction) { - await interaction.reply(Messages.info("Bot shutting down...", {ephemeral: true})); - await interaction.client.destroy(); - process.exit(0); - }, -}; + if (group === "send") { + if (subcommand === "channel") return await send(interaction, interaction.options.getChannel("channel", true)); + if (subcommand === "user") return await send(interaction, interaction.options.getUser("user", true)); + } + if (subcommand === "forwarding") return await forwarding(interaction); + if (subcommand === "quit") return await quit(interaction); + } +}); diff --git a/src/commands/cleanname.ts b/src/commands/cleanname.ts index 7f40f9f..84b60f8 100644 --- a/src/commands/cleanname.ts +++ b/src/commands/cleanname.ts @@ -1,68 +1,100 @@ -import {ActionRowBuilder, ChatInputCommandInteraction, ComponentType, EmbedBuilder, PermissionFlagsBits, RoleSelectMenuBuilder, RoleSelectMenuInteraction, SlashCommandBuilder} from "discord.js"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChatInputCommandInteraction, ComponentType, InteractionContextType, MessageFlags, PermissionFlagsBits, SelectMenuDefaultValueType} from "discord.js"; +import {container, defineCommand, defineComponent, row, text, type ComponentMessage} from "../framework"; import {humanReadableUptime} from "../util/time"; -import Colors from "../util/colors"; -import Messages from "../util/messages"; +import {Accents} from "../util/colors"; +import * as notices from "../util/notices"; import {guildDB} from "../db"; - - - -const weirdCharsRegex = /[^A-Za-z0-9\-_\\. ]/g; - -export default { - data: new SlashCommandBuilder() - .setName("cleanname") - .setDescription("Cleans member display names to match Discord's username standards.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand( - c => c.setName("join").setDescription("Toggles automatically cleaning new members when they join.") - .addBooleanOption((/** @type {import("@discordjs/builders").SlashCommandBooleanOption} */ option) => - option.setName("enabled") - .setDescription("Whether members should have their display name cleaned upon joining.") - .setRequired(true))) - .addSubcommand( - c => c.setName("user").setDescription("Fixes a display name for a single user.") - .addUserOption((/** @type {import("@discordjs/builders").SlashCommandUserOption} */ option) => - option.setName("user") - .setDescription("Whose display name should be cleaned?") - .setRequired(true))) - .addSubcommand(c => c.setName("server").setDescription("Fixes all display names in the server.")), - - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "server") return await this.server(interaction); - if (command === "user") return await this.user(interaction); - if (command === "join") return await this.join(interaction); - }, - - - async server(interaction: ChatInputCommandInteraction<"cached">) { - const controls = new ActionRowBuilder().addComponents( - new RoleSelectMenuBuilder({type: ComponentType.RoleSelect}).setCustomId("cleanname").setMinValues(0).setMaxValues(25).setDefaultRoles(interaction.guild.roles.highest.id) - ); - await interaction.reply(Messages.info("Please select which roles should bypass this cleaning.", {components: [controls]})); - }, - - - async role(interaction: RoleSelectMenuInteraction<"cached">) { +import {hasDisallowedChars} from "../util/names"; + + +interface CleanProgress { + members: number; + fixed: number; + failed: number; + blurb: string; + stamp: {label: string; at: number;}; + done: boolean; +} + +/** + * Replaces the progress embed. Components V2 has no inline field grid, so the + * three counters render as one line, and the embed timestamp becomes Discord's + * own markup so it still localises per viewer. + */ +function progress(state: CleanProgress): ComponentMessage { + return { + flags: MessageFlags.IsComponentsV2, + components: [container([ + text("## Fixing Display Names"), + text(state.blurb), + text(`**Members** ${state.members.toLocaleString()}\u2003**Fixed** ${state.fixed.toLocaleString()}\u2003**Failed** ${state.failed.toLocaleString()}`), + text(`-# ${state.stamp.label} `) + ], {accentColor: state.done ? Accents.Success : Accents.Info})] + }; +} + + +async function server(interaction: ChatInputCommandInteraction<"cached">) { + const controls = row({ + type: ComponentType.RoleSelect, + customId: chooseBypassRoles.customId({}), + minValues: 0, + maxValues: 25, + defaultValues: [{id: interaction.guild.roles.highest.id, type: SelectMenuDefaultValueType.Role}] + }); + await interaction.reply(notices.info("Please select which roles should bypass this cleaning.", {components: [controls]})); +} + + + + +async function user(interaction: ChatInputCommandInteraction<"cached">) { + const targetUser = interaction.options.getUser("user", true); + const member = interaction.guild.members.cache.get(targetUser.id); + if (!member) return await interaction.reply(notices.error("This user is not in the server.", {ephemeral: true})); + const isClean = !hasDisallowedChars(member.displayName); + if (isClean) return await interaction.reply(notices.info("This member's display name already conforms to the username standards.")); + try { + await member.setNickname(member.user.username); + await interaction.reply(notices.success("Successfully cleaned this member's display name.")); + } + catch { + await interaction.reply(notices.error("Could not clean this member's display name. Double check that I have permission to do so.")); + } +} + + +async function join(interaction: ChatInputCommandInteraction<"cached">) { + const toEnable = !!interaction.options.getBoolean("enabled"); + const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; + const current = guildSettings.cleanOnJoin; + if (current === toEnable) return await interaction.reply(notices.info(`This setting was already ${current ? "enabled" : "disabled"}.`)); + guildSettings.cleanOnJoin = toEnable; + await guildDB.set(interaction.guild.id, guildSettings); + await interaction.reply(notices.success(`This setting is now ${toEnable ? "enabled" : "disabled"}.`)); +} + + +/** The bypass-role picker shown by `/cleanname server`. */ +const chooseBypassRoles = defineComponent({ + id: "cleanname.bypass", + kind: "roleSelect", + guildOnly: true, + params: {}, + + async run(interaction) { const roleIds = [...interaction.roles.keys()]; const start = Date.now(); - const infoEmbed = new EmbedBuilder(); - infoEmbed.setColor(Colors.Info); - infoEmbed.setTitle("Fixing Display Names"); - infoEmbed.setDescription(`This will take approximately ${humanReadableUptime(interaction.guild.memberCount * 10)}. Please be patient.`); - infoEmbed.setFooter({text: "Started at"}); - infoEmbed.setTimestamp(start); - infoEmbed.setFields( - {name: "Members", value: interaction.guild.memberCount.toString(), inline: true}, - {name: "Fixed", value: "0", inline: true}, - {name: "Failed", value: "0", inline: true}, - ); - - await interaction.update({embeds: [infoEmbed], components: []}); + await interaction.update(progress({ + members: interaction.guild.memberCount, + fixed: 0, + failed: 0, + blurb: `This will take approximately ${humanReadableUptime(interaction.guild.memberCount * 10)}. Please be patient.`, + stamp: {label: "Started", at: start}, + done: false + })); let changed = 0; let failed = 0; @@ -70,7 +102,7 @@ export default { const members = interaction.guild.members.cache; for (const [, member] of members) { // If their name is fine continue - if (!weirdCharsRegex.test(member.displayName)) continue; + if (!hasDisallowedChars(member.displayName)) continue; // If they have a role that was selected as a bypass role, continue if (member.roles.cache.hasAny(...roleIds)) continue; @@ -87,44 +119,66 @@ export default { const finish = Date.now(); - infoEmbed.setFields( - {name: "Members", value: members.size.toString(), inline: true}, - {name: "Fixed", value: changed.toString(), inline: true}, - {name: "Failed", value: failed.toString(), inline: true}, - ); - - infoEmbed.setDescription(`Operation took ${humanReadableUptime(finish - start)}. Thank you for waiting.`); - infoEmbed.setColor(Colors.Success); - infoEmbed.setFooter({text: "Completed at"}); - infoEmbed.setTimestamp(finish); - - await interaction.update({embeds: [infoEmbed]}); - }, - - - async user(interaction: ChatInputCommandInteraction<"cached">) { - const targetUser = interaction.options.getUser("user", true); - const member = interaction.guild.members.cache.get(targetUser.id); - if (!member) return await interaction.reply(Messages.error("This user is not in the server.", {ephemeral: true})); - const isClean = !weirdCharsRegex.test(member.displayName); - if (isClean) return await interaction.reply(Messages.info("This member's display name already conforms to the username standards.")); - try { - await member.setNickname(member.user.username); - await interaction.reply(Messages.success("Successfully cleaned this member's display name.")); - } - catch { - await interaction.reply(Messages.error("Could not clean this member's display name. Double check that I have permission to do so.")); - } + // editReply, not update: the interaction was already acknowledged above, + // so a second update() throws InteractionAlreadyReplied and the final + // counts never reached the user. + await interaction.editReply(progress({ + members: members.size, + fixed: changed, + failed, + blurb: `Operation took ${humanReadableUptime(finish - start)}. Thank you for waiting.`, + stamp: {label: "Completed", at: finish}, + done: true + })); + } +}); + + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "cleanname", + description: "Cleans member display names to match Discord's username standards.", + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + contexts: [InteractionContextType.Guild], + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: "join", + description: "Toggles automatically cleaning new members when they join.", + options: [{ + type: ApplicationCommandOptionType.Boolean, + name: "enabled", + description: "Whether members should have their display name cleaned upon joining.", + required: true + }] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "user", + description: "Fixes a display name for a single user.", + options: [{ + type: ApplicationCommandOptionType.User, + name: "user", + description: "Whose display name should be cleaned?", + required: true + }] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "server", + description: "Fixes all display names in the server." + } + ] }, + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "server") return await server(interaction); + if (subcommand === "user") return await user(interaction); + if (subcommand === "join") return await join(interaction); + } +}); - async join(interaction: ChatInputCommandInteraction<"cached">) { - const toEnable = !!interaction.options.getBoolean("enabled"); - const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; - const current = guildSettings.cleanOnJoin; - if (current === toEnable) return await interaction.reply(Messages.info(`This setting was already ${current ? "enabled" : "disabled"}.`)); - guildSettings.cleanOnJoin = toEnable; - await guildDB.set(interaction.guild.id, guildSettings); - await interaction.reply(Messages.success(`This setting is now ${toEnable ? "enabled" : "disabled"}.`)); - }, -}; +export const components = [chooseBypassRoles]; diff --git a/src/commands/developer.ts b/src/commands/developer.ts index 3484953..8dc3a82 100644 --- a/src/commands/developer.ts +++ b/src/commands/developer.ts @@ -1,6 +1,8 @@ -import {ChatInputCommandInteraction, InteractionContextType, SlashCommandBuilder, type GuildTextBasedChannel} from "discord.js"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChatInputCommandInteraction, InteractionContextType, type GuildTextBasedChannel} from "discord.js"; +import {defineCommand} from "../framework"; +import config from "../config"; import {guildDB} from "../db"; -import Messages from "../util/messages"; +import * as notices from "../util/notices"; @@ -8,143 +10,163 @@ const message = `Hi {{user}}, you have just been given the {{role}} role in the const dmMessage = `If you weren't already aware, we have a developer community server where developers can interact, help each other, and ask questions about creating plugins and themes. It's also the primary location for upcoming BetterDiscord news and announcements for developers. We'd love for you to join us if you haven't done so already: https://discord.gg/hC9wzzQeZv`; const channelMessage = `By the way, normally this would have been sent to your DMs, but it seems your privacy settings prevented that. As a heads up, a lot of the information and communication from the website comes through DMs, so I would recommend adjusting that privacy option at least for the developer community server!`; -export default { - data: new SlashCommandBuilder() - .setName("developer") - .setDescription("Manage roles for developers in the community.") - .setContexts(InteractionContextType.Guild) - .addSubcommand( - c => c.setName("add").setDescription("Adds a new developer or new role to an existing developer.") - .addUserOption(opt => - opt.setName("user").setDescription("Who is the developer in question?").setRequired(true) - ) - .addStringOption(opt => - opt.setName("role").setDescription("Role to add.").setRequired(true) - .addChoices({name: "Plugin Developer", value: "Plugin Developer"}, {name: "Theme Developer", value: "Theme Developer"}) - ) - ) - .addSubcommand( - c => c.setName("sync").setDescription("Syncs roles between severs.") - .addUserOption(opt => - opt.setName("user").setDescription("Which developer to resync?").setRequired(true) - ) - ) - .addSubcommand( - c => c.setName("channel").setDescription("Sets a channel to send invite messages.") - // .addStringOption(opt => - // opt.setName("guildId").setDescription("Which server to use as a base?").setRequired(false) - // ) - .addStringOption(opt => - opt.setName("channel").setDescription("Which channel ID to send invites?").setRequired(false) - ) - ), - - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "channel") return await this.channel(interaction); - if (command === "sync") return await this.sync(interaction); - if (command === "add") return await this.add(interaction); - }, - - - async channel(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.member.permissions.has("Administrator")) return await interaction.reply(Messages.error("You need to be an administrator to use this command!", {ephemeral: true})); - const targetChannelId = interaction.options.getString("channel"); - - // const targetGuild = await interaction.client.guilds.fetch(targetGuildId); - const targetChannel = targetChannelId ? await interaction.client.channels.fetch(targetChannelId) : null; - - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (targetChannel) { - current.inviteChannel = targetChannel.id; - await guildDB.set(interaction.guild.id, current); +async function channel(interaction: ChatInputCommandInteraction<"cached">) { + if (!interaction.member.permissions.has("Administrator")) return await interaction.reply(notices.error("You need to be an administrator to use this command!", {ephemeral: true})); + const targetChannelId = interaction.options.getString("channel"); + + // const targetGuild = await interaction.client.guilds.fetch(targetGuildId); + const targetChannel = targetChannelId ? await interaction.client.channels.fetch(targetChannelId) : null; + + const current = await guildDB.get(interaction.guild.id) ?? {}; + if (targetChannel) { + current.inviteChannel = targetChannel.id; + await guildDB.set(interaction.guild.id, current); + } + else { + delete current.inviteChannel; + await guildDB.set(interaction.guild.id, current); + } + await interaction.reply(notices.success(targetChannel ? `Invite message channel set to <#${targetChannel.id}>!` : "Invite message channel has been unset!", {ephemeral: true})); +} + + +async function add(interaction: ChatInputCommandInteraction<"cached">) { + if (!interaction.member.permissions.has("ManageRoles")) return await interaction.reply(notices.error("You need the `Manage Roles` permission to use this command!", {ephemeral: true})); + await interaction.deferReply({ephemeral: true}); + const targetUser = interaction.options.getUser("user", true); + const roleName = interaction.options.getString("role", true); + + const bdRoleId = roleName.toLowerCase().includes("plugin") ? config.roles.pluginDeveloper : config.roles.themeDeveloper; + const bdGuild = await interaction.client.guilds.fetch(config.guilds.betterDiscord); + try { + const member = await bdGuild.members.fetch(targetUser); + try { + await member.roles.add(bdRoleId, "Developer verified"); } - else { - delete current.inviteChannel; - await guildDB.set(interaction.guild.id, current); + catch { + await interaction.editReply(notices.error("Could not add roles in main server!", {ephemeral: true})); } - await interaction.reply(Messages.success(targetChannel ? `Invite message channel set to <#${targetChannel.id}>!` : "Invite message channel has been unset!", {ephemeral: true})); - }, - - - async add(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.member.permissions.has("ManageRoles")) return await interaction.reply(Messages.error("You need the `Manage Roles` permission to use this command!", {ephemeral: true})); - await interaction.deferReply({ephemeral: true}); - const targetUser = interaction.options.getUser("user", true); - const roleName = interaction.options.getString("role", true); - - const bdRoleId = roleName.toLowerCase().includes("plugin") ? "125166040689803264" : "165005972970930176"; - const bdGuild = await interaction.client.guilds.fetch("86004744966914048"); - try { - const member = await bdGuild.members.fetch(targetUser); + } + catch { + await interaction.editReply(notices.error("User is not in BetterDiscord server!", {ephemeral: true})); + } + + let messageToSend = message.replace("{{user}}", `<@!${targetUser.id}>`).replace("{{role}}", roleName); + try { + const isMember = await interaction.guild.members.fetch(targetUser); + if (!isMember) messageToSend += "\n\n" + dmMessage; + } + catch { + messageToSend += "\n\n" + dmMessage; + } + + + try { + await targetUser.send(messageToSend); + } + catch { + await interaction.editReply(notices.error("Could not DM user!", {ephemeral: true})); + + const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; + if (guildSettings.inviteChannel) { + messageToSend += "\n\n" + channelMessage; + /** @type {import("discord.js").GuildTextBasedChannel} */ + const inviteChannel = await interaction.client.channels.fetch(guildSettings.inviteChannel) as GuildTextBasedChannel; try { - await member.roles.add(bdRoleId, "Developer verified"); + await inviteChannel?.send(messageToSend); } catch { - await interaction.editReply(Messages.error("Could not add roles in main server!", {ephemeral: true})); + await interaction.editReply(notices.error("Could not send a message in the invite channel!", {ephemeral: true})); } } - catch { - await interaction.editReply(Messages.error("User is not in BetterDiscord server!", {ephemeral: true})); - } - - let messageToSend = message.replace("{{user}}", `<@!${targetUser.id}>`).replace("{{role}}", roleName); - try { - const isMember = await interaction.guild.members.fetch(targetUser); - if (!isMember) messageToSend += "\n\n" + dmMessage; - } - catch { - messageToSend += "\n\n" + dmMessage; - } - - - try { - await targetUser.send(messageToSend); + else { + await interaction.editReply(notices.error("Could not DM user and no fallback channel exists!", {ephemeral: true})); } - catch { - await interaction.editReply(Messages.error("Could not DM user!", {ephemeral: true})); - - const guildSettings = await guildDB.get(interaction.guild.id) ?? {}; - if (guildSettings.inviteChannel) { - messageToSend += "\n\n" + channelMessage; - /** @type {import("discord.js").GuildTextBasedChannel} */ - const inviteChannel = await interaction.client.channels.fetch(guildSettings.inviteChannel) as GuildTextBasedChannel; - try { - await inviteChannel?.send(messageToSend); - } - catch { - await interaction.editReply(Messages.error("Could not send a message in the invite channel!", {ephemeral: true})); - } - } - else { - await interaction.editReply(Messages.error("Could not DM user and no fallback channel exists!", {ephemeral: true})); + } + + await interaction.editReply(notices.success("Role has been added successfully!", {ephemeral: true})); +} + + +async function sync(interaction: ChatInputCommandInteraction<"cached">) { + const targetUser = interaction.options.getUser("user", true); + const bdGuild = await interaction.client.guilds.fetch(config.guilds.betterDiscord); + const bdMember = await bdGuild.members.fetch(targetUser); + if (!bdMember) return await interaction.reply(notices.error("User is not in BetterDiscord server!", {ephemeral: true})); + const isPluginDev = bdMember.roles.cache.has(config.roles.pluginDeveloper); + const isThemeDev = bdMember.roles.cache.has(config.roles.themeDeveloper); + const rolesToAdd = [isPluginDev ? config.roles.communityPluginDeveloper : "", isThemeDev ? config.roles.communityThemeDeveloper : ""].filter(r => r); + + const communityMember = await interaction.guild.members.fetch(targetUser); + + try { + await communityMember.roles.add(rolesToAdd, "Syncing roles from main server"); + } + catch { + return await interaction.reply(notices.error("Could not assign roles in this server!", {ephemeral: true})); + } + + await interaction.reply(notices.success("Roles have been synced!", {ephemeral: true})); +} + + +const userOption = (description: string) => ({ + type: ApplicationCommandOptionType.User as const, + name: "user", + description, + required: true +}); + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "developer", + description: "Manage roles for developers in the community.", + contexts: [InteractionContextType.Guild], + options: [ + { + type: ApplicationCommandOptionType.Subcommand, + name: "add", + description: "Adds a new developer or new role to an existing developer.", + options: [ + userOption("Who is the developer in question?"), + { + type: ApplicationCommandOptionType.String, + name: "role", + description: "Role to add.", + required: true, + choices: [ + {name: "Plugin Developer", value: "Plugin Developer"}, + {name: "Theme Developer", value: "Theme Developer"} + ] + } + ] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "sync", + description: "Syncs roles between servers.", + options: [userOption("Which developer to resync?")] + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "channel", + description: "Sets a channel to send invite messages.", + options: [{ + type: ApplicationCommandOptionType.String, + name: "channel", + description: "Which channel ID to send invites?", + required: false + }] } - } - - await interaction.editReply(Messages.success("Role has been added successfully!", {ephemeral: true})); - }, - - - async sync(interaction: ChatInputCommandInteraction<"cached">) { - const targetUser = interaction.options.getUser("user", true); - const bdGuild = await interaction.client.guilds.fetch("86004744966914048"); - const bdMember = await bdGuild.members.fetch(targetUser); - if (!bdMember) return await interaction.reply(Messages.error("User is not in BetterDiscord server!", {ephemeral: true})); - const isPluginDev = bdMember.roles.cache.has("125166040689803264"); - const isThemeDev = bdMember.roles.cache.has("165005972970930176"); - const rolesToAdd = [isPluginDev ? "948627723830591568" : "", isThemeDev ? "948627648706392104" : ""].filter(r => r); - - const communityMember = await interaction.guild.members.fetch(targetUser); - - try { - await communityMember.roles.add(rolesToAdd, "Syncing roles from main server"); - } - catch { - return await interaction.reply(Messages.error("Could not assign roles in this server!", {ephemeral: true})); - } - - await interaction.reply(Messages.success("Roles have been synced!", {ephemeral: true})); + ] }, -}; + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "channel") return await channel(interaction); + if (subcommand === "sync") return await sync(interaction); + if (subcommand === "add") return await add(interaction); + } +}); diff --git a/src/commands/moderation.ts b/src/commands/moderation.ts index f34a8f0..6c880b9 100644 --- a/src/commands/moderation.ts +++ b/src/commands/moderation.ts @@ -1,108 +1,76 @@ -import {ChannelType, ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChannelType, ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits} from "discord.js"; +import {defineCommand} from "../framework"; import {guildDB} from "../db"; -import Messages from "../util/messages"; - - - -export default { - data: new SlashCommandBuilder() - .setName("moderation") - .setDescription("Commands for moderating the server.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setContexts(InteractionContextType.Guild) - .addSubcommand( - c => c.setName("invitefilter").setDescription("Toggles the invite filter module.") - .addBooleanOption(opt => - opt.setName("enable").setDescription("Enable or disable").setRequired(false) - ) - ) - .addSubcommand( - c => c.setName("detectspam").setDescription("Toggles the spam detection module.") - .addBooleanOption(opt => - opt.setName("enable").setDescription("Enable or disable").setRequired(false) - ) - ) - .addSubcommand( - c => c.setName("modlog").setDescription("Sets a channel to log bot moderation actions.") - .addChannelOption(opt => - opt.setName("channel").setDescription("Where to log my actions?").setRequired(false) - .addChannelTypes(ChannelType.GuildText) - ) - ) - .addSubcommand( - c => c.setName("joinleave").setDescription("Sets a channel to log join/leave messages.") - .addChannelOption(opt => - opt.setName("channel").setDescription("Where to log join/leave messages?").setRequired(false) - .addChannelTypes(ChannelType.GuildText) - ) - ), - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "invitefilter") return await this.invitefilter(interaction); - if (command === "detectspam") return await this.detectspam(interaction); - if (command === "modlog") return await this.modlog(interaction); - if (command === "joinleave") return await this.joinleave(interaction); - }, +import * as notices from "../util/notices"; - /** - * TODO: de-dup with detectspam - */ - async invitefilter(interaction: ChatInputCommandInteraction<"cached">) { - const toEnable = interaction.options.getBoolean("enable"); - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (toEnable === null) return await interaction.reply(Messages.info(`This module is currently ${current.invitefilter ? "enabled" : "disabled"}.`, {ephemeral: true})); +type ModuleKey = "invitefilter" | "detectspam"; +type ChannelKey = "modlog" | "joinleave"; - current.invitefilter = toEnable; - await guildDB.set(interaction.guild.id, current); - await interaction.reply(Messages.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); - }, +/** Shared by invitefilter and detectspam, which were byte-identical apart from the key. */ +async function toggleModule(interaction: ChatInputCommandInteraction<"cached">, key: ModuleKey) { + const toEnable = interaction.options.getBoolean("enable"); + const current = await guildDB.get(interaction.guild.id) ?? {}; + if (toEnable === null) return await interaction.reply(notices.info(`This module is currently ${current[key] ? "enabled" : "disabled"}.`, {ephemeral: true})); + current[key] = toEnable; + await guildDB.set(interaction.guild.id, current); - // TODO: move this to spam.ts - async detectspam(interaction: ChatInputCommandInteraction<"cached">) { - const toEnable = interaction.options.getBoolean("enable"); - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (toEnable === null) return await interaction.reply(Messages.info(`This module is currently ${current.detectspam ? "enabled" : "disabled"}.`, {ephemeral: true})); + await interaction.reply(notices.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); +} - current.detectspam = toEnable; - await guildDB.set(interaction.guild.id, current); - await interaction.reply(Messages.success(`This module has been ${toEnable ? "enabled" : "disabled"}.`, {ephemeral: true})); - }, +/** Shared by modlog and joinleave, likewise. */ +async function setChannel(interaction: ChatInputCommandInteraction<"cached">, key: ChannelKey, label: string) { + const targetChannel = interaction.options.getChannel("channel"); + const current = await guildDB.get(interaction.guild.id) ?? {}; + if (targetChannel) current[key] = targetChannel.id; + else delete current[key]; + await guildDB.set(interaction.guild.id, current); - /** - * TODO: de-dup with joinleave - */ - async modlog(interaction: ChatInputCommandInteraction<"cached">) { - const targetChannel = interaction.options.getChannel("channel"); - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (targetChannel) { - current.modlog = targetChannel.id; - await guildDB.set(interaction.guild.id, current); - } - else { - delete current.modlog; - await guildDB.set(interaction.guild.id, current); - } - await interaction.reply(Messages.success(targetChannel ? `Modlog set to <#${targetChannel.id}>!` : "Modlog has been unset!", {ephemeral: true})); - }, + await interaction.reply(notices.success(targetChannel ? `${label} set to <#${targetChannel.id}>!` : `${label} has been unset!`, {ephemeral: true})); +} - async joinleave(interaction: ChatInputCommandInteraction<"cached">) { - const targetChannel = interaction.options.getChannel("channel"); - const current = await guildDB.get(interaction.guild.id) ?? {}; - if (targetChannel) { - current.joinleave = targetChannel.id; - await guildDB.set(interaction.guild.id, current); - } - else { - delete current.joinleave; - await guildDB.set(interaction.guild.id, current); - } - await interaction.reply(Messages.success(targetChannel ? `Join/leave set to <#${targetChannel.id}>!` : "Join/leave has been unset!", {ephemeral: true})); - }, +const toggleOption = { + type: ApplicationCommandOptionType.Boolean as const, + name: "enable", + description: "Enable or disable", + required: false }; + +const channelOption = (description: string) => ({ + type: ApplicationCommandOptionType.Channel as const, + name: "channel", + description, + required: false, + channel_types: [ChannelType.GuildText as const] +}); + + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "moderation", + description: "Commands for moderating the server.", + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + contexts: [InteractionContextType.Guild], + options: [ + {type: ApplicationCommandOptionType.Subcommand, name: "invitefilter", description: "Toggles the invite filter module.", options: [toggleOption]}, + {type: ApplicationCommandOptionType.Subcommand, name: "detectspam", description: "Toggles the spam detection module.", options: [toggleOption]}, + {type: ApplicationCommandOptionType.Subcommand, name: "modlog", description: "Sets a channel to log bot moderation actions.", options: [channelOption("Where to log my actions?")]}, + {type: ApplicationCommandOptionType.Subcommand, name: "joinleave", description: "Sets a channel to log join/leave messages.", options: [channelOption("Where to log join/leave messages?")]} + ] + }, + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "invitefilter") return await toggleModule(interaction, "invitefilter"); + if (subcommand === "detectspam") return await toggleModule(interaction, "detectspam"); + if (subcommand === "modlog") return await setChannel(interaction, "modlog", "Modlog"); + if (subcommand === "joinleave") return await setChannel(interaction, "joinleave", "Join/leave"); + } +}); diff --git a/src/commands/selfroles.ts b/src/commands/selfroles.ts index 6d005ec..cc7dcb2 100644 --- a/src/commands/selfroles.ts +++ b/src/commands/selfroles.ts @@ -1,96 +1,148 @@ -import {ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, EmbedBuilder, MessageComponentInteraction, PermissionFlagsBits, RoleSelectMenuBuilder, RoleSelectMenuInteraction, SlashCommandBuilder, StringSelectMenuBuilder, StringSelectMenuInteraction, StringSelectMenuOptionBuilder} from "discord.js"; +import { + ApplicationCommandType, ButtonStyle, ComponentType, InteractionContextType, + MessageFlags, PermissionFlagsBits, SelectMenuDefaultValueType, + type InteractionReplyOptions, type InteractionUpdateOptions, type MessageActionRowComponentData +} from "discord.js"; +import {container, defineCommand, defineComponent, oneOf, row, text} from "../framework"; import {selfrolesDB} from "../db"; -import Messages from "../util/messages"; -import Colors from "../util/colors"; - - - -export default { - data: new SlashCommandBuilder() - .setName("selfroles") - .setDescription("Allows users to self-assign roles.") - .setDMPermission(false), - - - async execute(interaction: MessageComponentInteraction<"cached">) { - const selfroles = await selfrolesDB.get(interaction.guild.id) ?? []; - const listingEmbed = new EmbedBuilder().setColor(Colors.Info).setTitle("Available Roles") - .setDescription(selfroles.length ? selfroles.map((r: string) => `- <@&${r}>`).join("\n") : "No roles have been configured by the admins."); +import * as notices from "../util/notices"; +import {Accents} from "../util/colors"; + + +const RETURN_TO_PANEL_DELAY = 3000; +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + +/** The listing plus its controls. Shared by the command and every component. */ +function panel(roleIds: string[], canManage: boolean): InteractionReplyOptions & InteractionUpdateOptions { + const controls: MessageActionRowComponentData[] = [ + {type: ComponentType.Button, customId: openPicker.customId({mode: "user"}), label: "Manage Your Roles", style: ButtonStyle.Success} + ]; + if (canManage) { + controls.push({type: ComponentType.Button, customId: openPicker.customId({mode: "admin"}), label: "Set Assignable Roles", style: ButtonStyle.Primary}); + } + + return { + flags: MessageFlags.IsComponentsV2, + components: [container([ + text("## Available Roles"), + text(roleIds.length ? roleIds.map(id => `- <@&${id}>`).join("\n") : "No roles have been configured by the admins."), + row(...controls) + ], {accentColor: Accents.Info})] + }; +} + + +/** + * One definition for both buttons. `mode` is a typed param, so the switch below + * is exhaustive by construction and `customId({mode: "usr"})` will not compile. + * This replaces the old `customId.split("-")[1]` dispatch inside `button()`. + */ +const openPicker = defineComponent({ + id: "selfroles.open", + kind: "button", + guildOnly: true, + params: {mode: oneOf("user", "admin")}, + + async run(interaction, {mode}) { + const assignable = await selfrolesDB.get(interaction.guild.id) ?? []; - const controls = new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId("selfroles-user").setLabel("Manage Your Roles").setStyle(ButtonStyle.Success) - ); - const member = interaction.guild.members.cache.get(interaction.user.id)!; - if (member.permissions.has(PermissionFlagsBits.ManageRoles)) { - controls.addComponents( - new ButtonBuilder().setCustomId("selfroles-admin").setLabel("Set Assignable Roles").setStyle(ButtonStyle.Primary) - ); + if (mode === "admin") { + if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageRoles)) { + return await interaction.reply(notices.error("You need the `Manage Roles` permission to do that.", {ephemeral: true})); + } + + return await interaction.update(notices.info("Please select which roles should be self-assignable.", { + components: [row({ + type: ComponentType.RoleSelect, + customId: setAssignable.customId({}), + minValues: 0, + maxValues: 25, + defaultValues: assignable.map(id => ({id, type: SelectMenuDefaultValueType.Role})) + })] + })); } - if (!interaction.replied) return await interaction.reply({embeds: [listingEmbed], components: [controls], ephemeral: true}); - await interaction.editReply({embeds: [listingEmbed], components: [controls]}); - }, - - - async button(interaction: ButtonInteraction<"cached">) { - const id = interaction.customId.split("-")[1]; - if (id === "user") return await this.buttonUser(interaction); - if (id === "admin") return await this.buttonAdmin(interaction); - }, - + // The previous version called setMaxValues(0) here, which Discord rejects, + // so the first press on a server with no configured roles always failed. + if (!assignable.length) { + return await interaction.reply(notices.info("No self-assignable roles have been set up yet.", {ephemeral: true})); + } - async buttonUser(interaction: ButtonInteraction<"cached">) { - const member = interaction.guild.members.cache.get(interaction.user.id)!; + return await interaction.update(notices.info("Please select which roles you want.", { + components: [row({ + type: ComponentType.StringSelect, + customId: chooseRoles.customId({}), + minValues: 0, + maxValues: assignable.length, + options: assignable.map(id => ({ + "label": interaction.guild.roles.cache.get(id)?.name ?? id, + "value": id, + "default": interaction.member.roles.cache.has(id) + })) + })] + })); + } +}); + + +const chooseRoles = defineComponent({ + id: "selfroles.choose", + kind: "stringSelect", + guildOnly: true, + params: {}, + + async run(interaction) { const assignable = await selfrolesDB.get(interaction.guild.id) ?? []; - const controls = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder().setCustomId("selfroles") - .setMinValues(0) - .setMaxValues(assignable.length) - .setOptions(assignable.map( - (roleId: string) => new StringSelectMenuOptionBuilder() - .setLabel(interaction.guild.roles.cache.get(roleId)!.name) - .setValue(roleId) - .setDefault(member.roles.cache.has(roleId)) - )) - ); - - await interaction.update(Messages.info("Please select which roles you want.", {components: [controls]})); - }, - - async select(interaction: StringSelectMenuInteraction<"cached">) { - const member = interaction.guild.members.cache.get(interaction.user.id)!; - const assignable = await selfrolesDB.get(interaction.guild.id) ?? []; try { - if (assignable.length) await member.roles.remove(assignable); - if (interaction.values.length) await member.roles.add(interaction.values); - await interaction.update(Messages.success("Successfully assigned your roles!", {components: []})); + const toRemove = assignable.filter(id => !interaction.values.includes(id)); + if (toRemove.length) await interaction.member.roles.remove(toRemove, "Self-roles"); + if (interaction.values.length) await interaction.member.roles.add(interaction.values, "Self-roles"); + await interaction.update(notices.success("Successfully assigned your roles!", {components: []})); } catch { - await interaction.update(Messages.error("Could not assign your roles. It may be a permission issue.")); + await interaction.update(notices.error("Could not assign your roles. It may be a permission issue.", {components: []})); } - // Restart the flow - await new Promise(r => setTimeout(r, 3000)); - await this.execute(interaction); - }, + await wait(RETURN_TO_PANEL_DELAY); + await interaction.editReply(panel(assignable, interaction.memberPermissions.has(PermissionFlagsBits.ManageRoles))); + } +}); - async buttonAdmin(interaction: ButtonInteraction<"cached">) { - const defaultRoles = await selfrolesDB.get(interaction.guild.id) ?? []; - const controls = new ActionRowBuilder().addComponents( - new RoleSelectMenuBuilder().setCustomId("selfroles").setMaxValues(25).setDefaultRoles(defaultRoles) - ); - await interaction.update(Messages.info("Please select which roles should be self-assignable.", {components: [controls]})); - }, +const setAssignable = defineComponent({ + id: "selfroles.set", + kind: "roleSelect", + guildOnly: true, + params: {}, + + async run(interaction) { + const roleIds = [...interaction.roles.keys()]; + await selfrolesDB.set(interaction.guild.id, roleIds); + await interaction.update(notices.success("Self-assignable roles set successfully.", {components: []})); + await wait(RETURN_TO_PANEL_DELAY); + await interaction.editReply(panel(roleIds, true)); + } +}); - async role(interaction: RoleSelectMenuInteraction<"cached">) { - await selfrolesDB.set(interaction.guild.id, [...interaction.roles.keys()]); - await interaction.update(Messages.success("Self-assignable roles set successfully.", {components: []})); - // Restart the flow - await new Promise(r => setTimeout(r, 3000)); - await this.execute(interaction); +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "selfroles", + description: "Allows users to self-assign roles.", + contexts: [InteractionContextType.Guild] }, -}; + + async execute(interaction) { + const assignable = await selfrolesDB.get(interaction.guild.id) ?? []; + const canManage = interaction.memberPermissions.has(PermissionFlagsBits.ManageRoles); + const message = panel(assignable, canManage); + return await interaction.reply({...message, flags: MessageFlags.IsComponentsV2 | MessageFlags.Ephemeral}); + } +}); + +export const components = [openPicker, chooseRoles, setAssignable]; diff --git a/src/commands/spam.ts b/src/commands/spam.ts index 8802eed..47cb392 100644 --- a/src/commands/spam.ts +++ b/src/commands/spam.ts @@ -1,43 +1,53 @@ -import {ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; -import Messages from "../util/messages"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits} from "discord.js"; +import {defineCommand} from "../framework"; +import config from "../config"; +import * as notices from "../util/notices"; // TODO: move detectspam from moderation to here -export default { - data: new SlashCommandBuilder() - .setName("spam") - .setDescription("Commands for dealing with spam.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) - .setContexts(InteractionContextType.Guild) - .addSubcommand( - c => c.setName("link").setDescription("Adds a link to the automod spam link filter") - .addStringOption(opt => - opt.setName("link").setDescription("Link to add to the filter").setRequired(true) - ) - ), - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "link") return await this.link(interaction); +async function addLink(interaction: ChatInputCommandInteraction<"cached">) { + const rule = await interaction.guild.autoModerationRules.fetch(config.automod.spamLinkRule); + if (!rule) return await interaction.reply(notices.error("Spam link filter rule not found! Report this to Zerebos!", {ephemeral: true})); + + const existing = rule.triggerMetadata?.keywordFilter ?? []; + const link = interaction.options.getString("link", true); + + if (existing.includes(link)) return await interaction.reply(notices.info("This link is already in the spam filter!", {ephemeral: true})); + + await rule.edit({ + triggerMetadata: { + keywordFilter: [...existing, link], + } + }); + + // Don't make this ephemeral since it's useful to see who added what link + await interaction.reply(notices.success("Link added to spam filter!")); +} + + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "spam", + description: "Commands for dealing with spam.", + default_member_permissions: PermissionFlagsBits.ManageMessages.toString(), + contexts: [InteractionContextType.Guild], + options: [{ + type: ApplicationCommandOptionType.Subcommand, + name: "link", + description: "Adds a link to the automod spam link filter", + options: [{ + type: ApplicationCommandOptionType.String, + name: "link", + description: "Link to add to the filter", + required: true + }] + }] }, - - async link(interaction: ChatInputCommandInteraction<"cached">) { - const rule = await interaction.guild.autoModerationRules.fetch("1256935881168781332"); - if (!rule) return await interaction.reply(Messages.error("Spam link filter rule not found! Report this to Zerebos!", {ephemeral: true})); - - const existing = rule.triggerMetadata?.keywordFilter ?? []; - const link = interaction.options.getString("link", true); - - if (existing.includes(link)) return await interaction.reply(Messages.info("This link is already in the spam filter!", {ephemeral: true})); - - await rule.edit({ - triggerMetadata: { - keywordFilter: [...existing, link], - } - }); - - // Don't make this ephemeral since it's useful to see who added what link - await interaction.reply(Messages.success("Link added to spam filter!")); - }, -}; + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "link") return await addLink(interaction); + } +}); diff --git a/src/commands/tags.ts b/src/commands/tags.ts new file mode 100644 index 0000000..effacc7 --- /dev/null +++ b/src/commands/tags.ts @@ -0,0 +1,165 @@ +import { + ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, + AutocompleteInteraction, ChatInputCommandInteraction, ComponentType, InteractionContextType, + MessageFlags, type RESTPostAPIChatInputApplicationCommandsJSONBody +} from "discord.js"; +import {awaitModal, defineCommand} from "../framework"; +import type {AtLeast, Tag} from "../types"; +import {tagsDB} from "../db"; +import {msInMinute} from "../util/time"; +import {tagContainer, updateTagModal} from "../components/tags"; +import {error, info, success} from "../util/notices"; + + +const nameOption = (description: string, withAutocomplete: boolean) => ({ + type: ApplicationCommandOptionType.String as const, + name: "name", + description, + required: true, + autocomplete: withAutocomplete +}); + +const data: RESTPostAPIChatInputApplicationCommandsJSONBody = { + type: ApplicationCommandType.ChatInput, + name: "tag", + description: "Saving and recalling custom tags.", + contexts: [InteractionContextType.Guild], + integration_types: [ApplicationIntegrationType.GuildInstall], + options: [ + {type: ApplicationCommandOptionType.Subcommand, name: "list", description: "List all tags in this server"}, + {type: ApplicationCommandOptionType.Subcommand, name: "view", description: "View a tag", options: [nameOption("Name of the tag to view", true)]}, + {type: ApplicationCommandOptionType.Subcommand, name: "update", description: "Update a tag", options: [nameOption("Name of the tag to update", true)]}, + {type: ApplicationCommandOptionType.Subcommand, name: "delete", description: "Delete a tag", options: [nameOption("Name of the tag to delete", true)]}, + {type: ApplicationCommandOptionType.Subcommand, name: "create", description: "Create a new tag", options: [nameOption("Name of the tag to create", false)]} + ] +}; + + +async function view(interaction: ChatInputCommandInteraction<"cached">) { + await interaction.deferReply(); + const tagName = interaction.options.getString("name", true); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tag = guildTags[tagName]; + if (!tag) { + return await interaction.editReply(error(`Tag with name \`${tagName}\` does not exist.`)); + } + + return await interaction.editReply({ + flags: MessageFlags.IsComponentsV2, + components: [tagContainer(tag)] + }); +} + +async function create(interaction: ChatInputCommandInteraction<"cached">) { + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(error("You do not have permission to create tags.", {ephemeral: true})); + const tagName = interaction.options.getString("name", true); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tag = guildTags[tagName]; + if (tag) return await interaction.reply(error(`Tag with name \`${tagName}\` already exists.`, {ephemeral: true})); + return await showTagModal(interaction, {name: tagName}); +} + +async function update(interaction: ChatInputCommandInteraction<"cached">) { + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.reply(error("You do not have permission to update tags.", {ephemeral: true})); + const tagName = interaction.options.getString("name", true); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tag = guildTags[tagName]; + if (!tag) return await interaction.reply(error(`Tag with name \`${tagName}\` does not exist.`, {ephemeral: true})); + return await showTagModal(interaction, tag); +} + +async function remove(interaction: ChatInputCommandInteraction<"cached">) { + await interaction.deferReply({flags: MessageFlags.Ephemeral}); + if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(error("You do not have permission to delete tags.")); + const tagName = interaction.options.getString("name", true); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tag = guildTags[tagName]; + if (!tag) { + return await interaction.editReply(error(`Tag with name \`${tagName}\` does not exist.`)); + } + + delete guildTags[tagName]; + await tagsDB.set(interaction.guildId, guildTags); + + return await interaction.editReply(success(`Tag with name \`${tagName}\` has been deleted.`)); +} + +async function list(interaction: ChatInputCommandInteraction<"cached">) { + await interaction.deferReply({flags: MessageFlags.Ephemeral}); + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tagNames = Object.keys(guildTags); + if (tagNames.length === 0) { + return await interaction.editReply(info("There are no tags in this server yet.")); + } + + return await interaction.editReply({ + flags: MessageFlags.IsComponentsV2, + components: [{ + type: ComponentType.Container, + components: [{ + type: ComponentType.TextDisplay, + content: `**Tags in this server:**\n${tagNames.map(name => `- \`${name}\``).join("\n")}` + }] + }] + }); +} + + +async function showTagModal(interaction: ChatInputCommandInteraction<"cached">, tag: AtLeast) { + const isUpdating = !!tag.content; + + // awaitModal returns null only on timeout, so a database failure below is no + // longer reported to the user as "submission timed out". + const submitted = await awaitModal(interaction, updateTagModal(tag), ["title", "content", "thumbnail"], {time: msInMinute * 5}); + if (!submitted) return await interaction.followUp(error("Modal submission timed out!")); + + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + guildTags[tag.name] = { + name: tag.name, + title: submitted.values.title || undefined, + content: submitted.values.content, + thumbnailUrl: submitted.values.thumbnail || undefined + }; + await tagsDB.set(interaction.guildId, guildTags); + + await submitted.submission.reply(success(`Tag \`${tag.name}\` has been ${isUpdating ? "updated" : "created"} successfully!`)); +} + + +/** Autocomplete for the tag-name option on view / update / delete. */ +async function autocomplete(interaction: AutocompleteInteraction<"cached">) { + const focusedValue = interaction.options.getFocused(); + + if (interaction.options.getSubcommand() === "view" || interaction.options.getSubcommand() === "update" || interaction.options.getSubcommand() === "delete") { + const guildTags = await tagsDB.get(interaction.guildId) ?? {}; + const tags = Object.keys(guildTags); + + const filtered = tags.filter(tag => tag.toLowerCase().startsWith(focusedValue.toLowerCase())); + const limited = filtered.slice(0, 25); + + return await interaction.respond( + limited.map(tag => ({name: tag, value: tag})) + ); + } + + return await interaction.respond([]); +} + + +export const command = defineCommand({ + guildOnly: true, + data, + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "view") return await view(interaction); + if (subcommand === "create") return await create(interaction); + if (subcommand === "update") return await update(interaction); + if (subcommand === "delete") return await remove(interaction); + if (subcommand === "list") return await list(interaction); + + return await interaction.reply(error("This command is not yet implemented.", {ephemeral: true})); + }, + + autocomplete +}); diff --git a/src/commands/tags.tsx b/src/commands/tags.tsx deleted file mode 100644 index 2866fb8..0000000 --- a/src/commands/tags.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import {AutocompleteInteraction, ChatInputCommandInteraction, MessageFlags, type ModalComponentData} from "discord.js"; -import type {AtLeast, Tag} from "../types"; -import {tagsDB} from "../db"; -import {msInMinute} from "../util/time"; -import {Tag as TagComponent, UpdateTagModal} from "../components/tags"; -import {ComponentMessage, Container, TextDisplay, type MessageOptions} from "@djsx"; -import {Error, Info, Success} from "@djsx/widgets/Messages"; -import {SlashCommand, StringOption, Subcommand} from "@djsx/commands/Command"; - - -export default { - data: - - - - - - - - - - - - - - , - - /** - * Main function for tag command - */ - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "view") return await this.view(interaction); - if (command === "create") return await this.create(interaction); - if (command === "update") return await this.update(interaction); - if (command === "delete") return await this.delete(interaction); - if (command === "list") return await this.list(interaction); - - return await interaction.editReply(This command is not yet implemented. as MessageOptions); - }, - - async view(interaction: ChatInputCommandInteraction<"cached">) { - await interaction.deferReply(); - const tagName = interaction.options.getString("name", true); - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tag = guildTags[tagName]; - if (!tag) { - return await interaction.editReply(Tag with name `{tagName}` does not exist. as MessageOptions); - } - - return await interaction.editReply( - ( - - ) as MessageOptions - ); - }, - - async create(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(You do not have permission to create tags. as MessageOptions); - const tagName = interaction.options.getString("name", true); - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tag = guildTags[tagName]; - if (tag) return await interaction.editReply(Tag with name `{tagName}` already exists. as MessageOptions); - return await this.showTagModal(interaction, {name: tagName}); - }, - - async update(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(You do not have permission to update tags. as MessageOptions); - const tagName = interaction.options.getString("name", true); - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tag = guildTags[tagName]; - if (!tag) return await interaction.editReply(Tag with name `{tagName}` does not exist. as MessageOptions); - return await this.showTagModal(interaction, tag); - }, - - async delete(interaction: ChatInputCommandInteraction<"cached">) { - if (!interaction.memberPermissions.has("ManageMessages")) return await interaction.editReply(You do not have permission to delete tags. as MessageOptions); - await interaction.deferReply({flags: MessageFlags.Ephemeral}); - const tagName = interaction.options.getString("name", true); - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tag = guildTags[tagName]; - if (!tag) { - return await interaction.editReply(Tag with name `{tagName}` does not exist. as MessageOptions); - } - - delete guildTags[tagName]; - await tagsDB.set(interaction.guildId, guildTags); - - return await interaction.editReply(Tag with name `{tagName}` has been deleted. as MessageOptions); - }, - - async list(interaction: ChatInputCommandInteraction<"cached">) { - await interaction.deferReply({flags: MessageFlags.Ephemeral}); - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tagNames = Object.keys(guildTags); - if (tagNames.length === 0) { - return await interaction.editReply(There are no tags in this server yet. as MessageOptions); - } - - return await interaction.editReply( - - - {`**Tags in this server:**\n${tagNames.map(name => `- \`${name}\``).join("\n")}`} - - as MessageOptions - ); - }, - - - async showTagModal(interaction: ChatInputCommandInteraction<"cached">, tag: AtLeast) { - const isUpdating = !!tag.content; - - await interaction.showModal( as ModalComponentData); - - try { - const modalInteraction = await interaction.awaitModalSubmit({time: msInMinute * 5}); - const title = modalInteraction.fields.getTextInputValue("title"); - const content = modalInteraction.fields.getTextInputValue("content"); - const thumbnailUrl = modalInteraction.fields.getTextInputValue("thumbnail"); - - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - guildTags[tag.name] = { - name: tag.name, - title: title || undefined, - content, - thumbnailUrl: thumbnailUrl || undefined, - }; - await tagsDB.set(interaction.guildId, guildTags); - - await modalInteraction.reply(Tag `{tag.name}` has been ${isUpdating ? "updated" : "created"} successfully! as MessageOptions); - } - catch { - await interaction.followUp(Modal submission timed out! as MessageOptions); - } - }, - - - /** - * Autocomplete handlers for tags - */ - async autocomplete(interaction: AutocompleteInteraction<"cached">) { - const focusedValue = interaction.options.getFocused(); - - if (interaction.options.getSubcommand() === "view" || interaction.options.getSubcommand() === "update" || interaction.options.getSubcommand() === "delete") { - const guildTags = await tagsDB.get(interaction.guildId) ?? {}; - const tags = Object.keys(guildTags); - - const filtered = tags.filter(tag => tag.toLowerCase().startsWith(focusedValue.toLowerCase())); - const limited = filtered.slice(0, 25); - - return await interaction.respond( - limited.map(tag => ({name: tag, value: tag})) - ); - } - - return await interaction.respond([]); - }, -}; diff --git a/src/commands/voicetext.ts b/src/commands/voicetext.ts index 9f1b4a8..0c8ad33 100644 --- a/src/commands/voicetext.ts +++ b/src/commands/voicetext.ts @@ -1,101 +1,103 @@ -import {ChannelType, ChatInputCommandInteraction, GuildChannel, OverwriteType, PermissionFlagsBits, SlashCommandBuilder} from "discord.js"; +import {ApplicationCommandOptionType, ApplicationCommandType, ChannelType, ChatInputCommandInteraction, GuildChannel, InteractionContextType, OverwriteType, PermissionFlagsBits} from "discord.js"; +import {defineCommand} from "../framework"; import {voicetextDB} from "../db"; -import Messages from "../util/messages"; - - -export default { - data: new SlashCommandBuilder() - .setName("voicetext") - .setDescription("Binds one voice and one text channel together.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand( - c => c.setName("status").setDescription("Checks the bound status of a voice channel.") - .addChannelOption(opt => - opt.setName("channel").setRequired(true) - .setDescription("Which voice channel to check?") - .addChannelTypes(ChannelType.GuildVoice) - ) - ) - .addSubcommand( - c => c.setName("unbind").setDescription("Unbinds a voice channel from it's partner.") - .addChannelOption(opt => - opt.setName("channel").setRequired(true) - .setDescription("Which voice channel to unbind?") - .addChannelTypes(ChannelType.GuildVoice) - ) - ) - .addSubcommand( - c => c.setName("bind").setDescription("Binds a voice and text channel together.") - .addChannelOption(opt => - opt.setName("voice").setRequired(true) - .setDescription("Which voice channel to bind?") - .addChannelTypes(ChannelType.GuildVoice) - ) - .addChannelOption(opt => - opt.setName("text").setRequired(true) - .setDescription("Which text channel to bind with?") - .addChannelTypes(ChannelType.GuildText) - ) - ), - - async execute(interaction: ChatInputCommandInteraction<"cached">) { - const command = interaction.options.getSubcommand(); - if (command === "bind") return await this.bind(interaction); - if (command === "unbind") return await this.unbind(interaction); - if (command === "status") return await this.status(interaction); - }, +import * as notices from "../util/notices"; - async bind(interaction: ChatInputCommandInteraction<"cached">) { - const voice = interaction.options.getChannel("voice", true); - const text = interaction.options.getChannel("text", true); - if (voice.type !== ChannelType.GuildVoice) return await interaction.reply(Messages.error("The voice channel must be a voice channel.", {ephemeral: true})); - if (text.type !== ChannelType.GuildText) return await interaction.reply(Messages.error("The text channel must be a text channel.", {ephemeral: true})); +const voiceOption = (description: string, name = "channel") => ({ + type: ApplicationCommandOptionType.Channel as const, + name, + description, + required: true, + channel_types: [ChannelType.GuildVoice as const] +}); - const partner = await voicetextDB.get(voice.id) ?? ""; - if (partner) return await interaction.reply(Messages.error(`<#${voice.id}> is already bound to <#${partner}>. Please unbind before continuing.`, {ephemeral: true})); +async function bind(interaction: ChatInputCommandInteraction<"cached">) { + const voice = interaction.options.getChannel("voice", true); + const text = interaction.options.getChannel("text", true); + if (voice.type !== ChannelType.GuildVoice) return await interaction.reply(notices.error("The voice channel must be a voice channel.", {ephemeral: true})); + if (text.type !== ChannelType.GuildText) return await interaction.reply(notices.error("The text channel must be a text channel.", {ephemeral: true})); + const partner = await voicetextDB.get(voice.id) ?? ""; + if (partner) return await interaction.reply(notices.error(`<#${voice.id}> is already bound to <#${partner}>. Please unbind before continuing.`, {ephemeral: true})); - try { - await text.permissionOverwrites.edit(interaction.guild.id, {SendMessages: false}, {reason: "Bind text and voice channel", type: OverwriteType.Role}); - } - catch (err) { - console.error(err); - return await interaction.reply(Messages.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); - } - await voicetextDB.set(voice.id, text.id); - await interaction.reply(Messages.success(`<#${voice.id}> is now bound to <#${text.id}>!`, {ephemeral: true})); - }, + try { + await text.permissionOverwrites.edit(interaction.guild.id, {SendMessages: false}, {reason: "Bind text and voice channel", type: OverwriteType.Role}); + } + catch (err) { + console.error(err); + return await interaction.reply(notices.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); + } + await voicetextDB.set(voice.id, text.id); + await interaction.reply(notices.success(`<#${voice.id}> is now bound to <#${text.id}>!`, {ephemeral: true})); +} - async unbind(interaction: ChatInputCommandInteraction<"cached">) { - const targetChannel = interaction.options.getChannel("channel", true); - const partner = await voicetextDB.get(targetChannel.id) ?? ""; - if (!partner) return await interaction.reply(Messages.error(`<#${targetChannel.id}> is not bound.`, {ephemeral: true})); - - /** - * @type {import("discord.js").GuildChannel} - */ - const text = interaction.guild.channels.cache.get(partner) as GuildChannel; - if (text.type !== ChannelType.GuildText) return await interaction.reply(Messages.error("The text channel must be a text channel.", {ephemeral: true})); - try { - await text.permissionOverwrites.edit(interaction.guild.id, {SendMessages: null}, {reason: "Unbind text and voice channel", type: OverwriteType.Role}); - } - catch (err) { - console.error(err); - return await interaction.reply(Messages.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); - } - - await voicetextDB.delete(targetChannel.id); - await interaction.reply(Messages.success(`<#${targetChannel.id}> is now unbound!`, {ephemeral: true})); - }, +async function unbind(interaction: ChatInputCommandInteraction<"cached">) { + const targetChannel = interaction.options.getChannel("channel", true); + const partner = await voicetextDB.get(targetChannel.id) ?? ""; + if (!partner) return await interaction.reply(notices.error(`<#${targetChannel.id}> is not bound.`, {ephemeral: true})); + + /** + * @type {import("discord.js").GuildChannel} + */ + const text = interaction.guild.channels.cache.get(partner) as GuildChannel; + if (text.type !== ChannelType.GuildText) return await interaction.reply(notices.error("The text channel must be a text channel.", {ephemeral: true})); + try { + await text.permissionOverwrites.edit(interaction.guild.id, {SendMessages: null}, {reason: "Unbind text and voice channel", type: OverwriteType.Role}); + } + catch (err) { + console.error(err); + return await interaction.reply(notices.error(`Unable to adjust permissions for <#${text.id}>. Make sure the bot has permission.`)); + } + + await voicetextDB.delete(targetChannel.id); + await interaction.reply(notices.success(`<#${targetChannel.id}> is now unbound!`, {ephemeral: true})); +} - async status(interaction: ChatInputCommandInteraction) { - const targetChannel = interaction.options.getChannel("channel", true); - const partner = await voicetextDB.get(targetChannel.id) ?? ""; - await interaction.reply(Messages.info(partner ? `<#${targetChannel.id}> is bound to <#${partner}>` : `This channel <#${targetChannel.id}> is not bound.`, {ephemeral: true})); + +async function status(interaction: ChatInputCommandInteraction) { + const targetChannel = interaction.options.getChannel("channel", true); + const partner = await voicetextDB.get(targetChannel.id) ?? ""; + await interaction.reply(notices.info(partner ? `<#${targetChannel.id}> is bound to <#${partner}>` : `This channel <#${targetChannel.id}> is not bound.`, {ephemeral: true})); +} + + +export const command = defineCommand({ + guildOnly: true, + data: { + type: ApplicationCommandType.ChatInput, + name: "voicetext", + description: "Binds one voice and one text channel together.", + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + contexts: [InteractionContextType.Guild], + options: [ + {type: ApplicationCommandOptionType.Subcommand, name: "status", description: "Checks the bound status of a voice channel.", options: [voiceOption("Which voice channel to check?")]}, + {type: ApplicationCommandOptionType.Subcommand, name: "unbind", description: "Unbinds a voice channel from its partner.", options: [voiceOption("Which voice channel to unbind?")]}, + { + type: ApplicationCommandOptionType.Subcommand, + name: "bind", + description: "Binds a voice and text channel together.", + options: [ + voiceOption("Which voice channel to bind?", "voice"), + { + type: ApplicationCommandOptionType.Channel as const, + name: "text", + description: "Which text channel to bind with?", + required: true, + channel_types: [ChannelType.GuildText as const] + } + ] + } + ] }, -}; + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "bind") return await bind(interaction); + if (subcommand === "unbind") return await unbind(interaction); + if (subcommand === "status") return await status(interaction); + } +}); diff --git a/src/components/tags.ts b/src/components/tags.ts new file mode 100644 index 0000000..a3ddad7 --- /dev/null +++ b/src/components/tags.ts @@ -0,0 +1,57 @@ +import { + ComponentType, TextInputStyle, + type ComponentInContainerData, type ContainerComponentData, type LabelComponentData, + type ModalComponentData, type TextDisplayComponentData +} from "discord.js"; +import type {AtLeast, Tag} from "../types"; + + +/** A tag rendered as a container, with an optional thumbnail alongside the text. */ +export function tagContainer(tag: Tag): ContainerComponentData { + const body: TextDisplayComponentData[] = []; + if (tag.title) body.push({type: ComponentType.TextDisplay, content: `# ${tag.title}`}); + body.push({type: ComponentType.TextDisplay, content: tag.content}); + + const components: ComponentInContainerData[] = tag.thumbnailUrl + ? [{ + type: ComponentType.Section, + components: body, + accessory: {type: ComponentType.Thumbnail, media: {url: tag.thumbnailUrl}} + }] + : body; + + return {type: ComponentType.Container, components}; +} + + +/** + * discord.js still marks `label` required on TextInputComponentData, even though + * the label now lives on the wrapping Label component. The djsx version omitted + * it (via `Omit` plus a cast in ModalLabel) and + * that is what currently ships, so we keep the payload identical rather than + * introduce an untested field. + * + * This is the one cast left in the tags code, down from eighteen, and it is + * confined to this helper. + */ +function field(customId: string, label: string, style: TextInputStyle, required: boolean, maxLength: number, value: string): LabelComponentData { + return { + type: ComponentType.Label, + label, + component: {type: ComponentType.TextInput, customId, style, required, maxLength, value} as LabelComponentData["component"] + }; +} + + +export function updateTagModal(tag: AtLeast): ModalComponentData { + const isUpdating = !!tag.content; + return { + customId: "tagmodal", + title: `${isUpdating ? "Update" : "Create"} Tag: ${tag.name}`, + components: [ + field("title", "Tag Title", TextInputStyle.Short, false, 100, tag.title || ""), + field("content", "Tag Content", TextInputStyle.Paragraph, true, 2000, tag.content || ""), + field("thumbnail", "Tag Thumbnail URL", TextInputStyle.Short, false, 2000, tag.thumbnailUrl || "") + ] + }; +} diff --git a/src/components/tags.tsx b/src/components/tags.tsx deleted file mode 100644 index c84c6bb..0000000 --- a/src/components/tags.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import {Container, TextDisplay, Section, Thumbnail, Modal, ModalLabel, TextInput, TextInputStyle} from "@djsx"; -import {type ContainerComponentData, type ModalComponentData} from "discord.js"; -import type {AtLeast, Tag} from "../types"; - - -export function Tag(tag: Tag) { - const text = <> - {tag.title && {`# ${tag.title}`}} - {tag.content} - ; - - const container = - {tag.thumbnailUrl - ?

}> - {text} -
- : text - } - ; - - return container as ContainerComponentData; -} - -export function UpdateTagModal(tag: AtLeast) { - const isUpdating = !!tag.content; - return ( - - - - - - - - - - ) as ModalComponentData; -} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6a66bb9 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,39 @@ +/** + * Every Discord snowflake and community-specific constant the bot depends on. + * + * These were inline literals scattered across six files — the BetterDiscord + * guild id appeared four times, the developer role ids five. Each entry may be + * overridden by an environment variable so the bot can be pointed at a test + * server without editing source. + */ + +const id = (key: string, fallback: string): string => process.env[key] || fallback; + +export const config = { + guilds: { + /** The main BetterDiscord server. */ + betterDiscord: id("BD_GUILD_ID", "86004744966914048") + }, + + roles: { + /** Roles in the main server that mark someone as a verified developer. */ + pluginDeveloper: id("BD_ROLE_PLUGIN_DEV", "125166040689803264"), + themeDeveloper: id("BD_ROLE_THEME_DEV", "165005972970930176"), + + /** The equivalents in the developer community server, kept in sync. */ + communityPluginDeveloper: id("COMMUNITY_ROLE_PLUGIN_DEV", "948627723830591568"), + communityThemeDeveloper: id("COMMUNITY_ROLE_THEME_DEV", "948627648706392104") + }, + + channels: { + /** Where compromised-account warnings are posted. */ + accountIssues: id("BD_CHANNEL_ACCOUNT_ISSUES", "1465301762821853204") + }, + + automod: { + /** The AutoMod rule whose keyword list `/spam link` appends to. */ + spamLinkRule: id("BD_AUTOMOD_SPAM_LINK_RULE", "1256935881168781332") + } +} as const; + +export default config; diff --git a/src/db.ts b/src/db.ts index c0baebc..04432c1 100644 --- a/src/db.ts +++ b/src/db.ts @@ -4,20 +4,47 @@ import Keyv from "keyv"; import Sqlite from "@keyv/sqlite"; import type {BdWebAddon, CommandStats, GuildSettings, Tag} from "./types"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Single SQLite connection string -const sqliteUri = "sqlite://" + path.resolve(__dirname, "..", "settings.sqlite3"); - -// Create one Sqlite store instance -const sqliteStore = new Sqlite(sqliteUri); - -// Export pre-configured database instances sharing the same store -export const guildDB = new Keyv(sqliteStore, {namespace: "settings"}); -export const globalDB = new Keyv(sqliteStore, {namespace: "global"}); -export const selfrolesDB = new Keyv(sqliteStore, {namespace: "selfroles"}); -export const voicetextDB = new Keyv(sqliteStore, {namespace: "voicetext"}); -export const statsDB = new Keyv(sqliteStore, {namespace: "stats"}); -export const tagsDB = new Keyv>(sqliteStore, {namespace: "tags"}); -export const userInstallNotices = new Keyv(sqliteStore, {namespace: "userInstallNotices"}); \ No newline at end of file + +const here = path.dirname(fileURLToPath(import.meta.url)); +const sqliteUri = "sqlite://" + path.resolve(here, "..", "settings.sqlite3"); + +/** + * The store is built on first use, not at import. + * + * Anything that loads a command module for its metadata — the loader, the + * deploy script, the test suite — pulls this file in transitively. Constructing + * the store is what opens the connection and writes settings.sqlite3, so doing + * it eagerly meant merely listing the commands created a database. Worse, it + * pulled the sqlite3 native addon into the test process, where it + * intermittently aborted the runner at exit with a NAPI panic (exit code 134) + * after every test had already passed. + */ +let store: Sqlite | undefined; + +/** For tests: whether anything has actually opened the database yet. */ +export const isStoreOpen = (): boolean => store !== undefined; + +function lazyKeyv(namespace: string): Keyv { + let instance: Keyv | undefined; + + return new Proxy({} as Keyv, { + get(_target, property) { + store ??= new Sqlite(sqliteUri); + instance ??= new Keyv(store, {namespace}); + + const value: unknown = Reflect.get(instance, property); + if (typeof value === "function") return (value as (...args: unknown[]) => unknown).bind(instance); + return value; + } + }); +} + + +// Pre-configured database instances, all sharing one store once it exists +export const guildDB = lazyKeyv("settings"); +export const globalDB = lazyKeyv("global"); +export const selfrolesDB = lazyKeyv("selfroles"); +export const voicetextDB = lazyKeyv("voicetext"); +export const statsDB = lazyKeyv("stats"); +export const tagsDB = lazyKeyv>("tags"); +export const userInstallNotices = lazyKeyv("userInstallNotices"); diff --git a/src/events/cleanname.ts b/src/events/cleanname.ts index 3d0edc6..d2dcb63 100644 --- a/src/events/cleanname.ts +++ b/src/events/cleanname.ts @@ -1,15 +1,12 @@ import {Events, type GuildMember} from "discord.js"; import {guildDB} from "../db"; - - -// TODO: put this somewhere common to avoid double maintenance -const weirdCharsRegex = /[^A-Za-z0-9\-_\\. ]/g; +import {hasDisallowedChars} from "../util/names"; export default { name: Events.GuildMemberAdd, async execute(member: GuildMember) { - if (!weirdCharsRegex.test(member.displayName)) return; // TODO: maybe log? + if (!hasDisallowedChars(member.displayName)) return; // TODO: maybe log? const guildSettings = await guildDB.get(member.guild.id); if (!guildSettings?.cleanOnJoin) return; diff --git a/src/events/detectcryptoscam.ts b/src/events/detectcryptoscam.ts index 994d802..d455f4f 100644 --- a/src/events/detectcryptoscam.ts +++ b/src/events/detectcryptoscam.ts @@ -1,11 +1,10 @@ -import {EmbedBuilder, Events, Message, PermissionFlagsBits} from "discord.js"; +import {Events, Message, PermissionFlagsBits} from "discord.js"; +import config from "../config"; import {guildDB} from "../db"; -import Colors from "../util/colors"; +import {sendModLog} from "../util/modlog"; const TIMEOUT_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds -const TARGET_GUILD_ID = "86004744966914048"; -const ACCOUNT_ISSUES_CHANNEL_ID = "1465301762821853204"; const sketchyImageRegex = /https:\/\/(?:cdn|media)\.(?:discord|discordapp)\.(?:com|net)\/attachments\/\d+\/\d+\/(?:[1234]|image)\.(?:jpg|png|webp)(?:\?.*?)?(?:\s+|$)/; // TODO: consider de-duping with invitefilter event @@ -16,7 +15,7 @@ export default { // Ignore DM messages and owner messages and people with manage messages perms if (!message.inGuild() || message.author.id === process.env.BOT_OWNER_ID) return; if (message.author.id === message.client.user.id) return; - if (message.guild.id !== TARGET_GUILD_ID) return; + if (message.guild.id !== config.guilds.betterDiscord) return; if (message.channel.permissionsFor(message.author)?.has(PermissionFlagsBits.ManageMessages)) return; // Obviously if this is disabled we don't need to do this stuff either @@ -48,24 +47,21 @@ export default { console.error("Could not timeout member. Likely permissions."); } - const accountIssuesChannel = message.guild.channels.cache.get(ACCOUNT_ISSUES_CHANNEL_ID); + const accountIssuesChannel = message.guild.channels.cache.get(config.channels.accountIssues); if (accountIssuesChannel && accountIssuesChannel.isTextBased()) { await accountIssuesChannel.send({content: `${message.author.toString()} (${message.author.id}) your account may be compromised! Change your password and remove any unfamiliar account connections and authorized apps.`}); } if (didTimeout) { - const modlogId = current.modlog; - const modlogChannel = message.guild.channels.cache.get(modlogId!); - if (!modlogId || !modlogChannel || !modlogChannel.isTextBased()) return; // Can't log - - const mEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: "Member Timed Out", iconURL: message.author.displayAvatarURL()}) - .setDescription(`${message.author.displayName} ${message.author.tag}`) - .addFields({name: "Reason", value: "Detected Crypto Scam"}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - - await modlogChannel.send({embeds: [mEmbed]}); + await sendModLog(message.guild, current.modlog, { + heading: "Member Timed Out", + iconUrl: message.author.displayAvatarURL(), + body: `${message.author.displayName} ${message.author.tag}`, + reason: "Detected Crypto Scam", + userId: message.author.id, + at: message.createdTimestamp + }); } }, }; \ No newline at end of file diff --git a/src/events/detectspam.ts b/src/events/detectspam.ts index 8fce715..f263a95 100644 --- a/src/events/detectspam.ts +++ b/src/events/detectspam.ts @@ -1,12 +1,14 @@ -import {EmbedBuilder, Events, Message, PermissionFlagsBits} from "discord.js"; +import {Events, Message, PermissionFlagsBits} from "discord.js"; import {guildDB} from "../db"; -import Colors from "../util/colors"; +import {sendModLog} from "../util/modlog"; const fakeDiscordRegex = new RegExp(`([a-zA-Z-\\.]+)?d[il][il]?scorr?(cl|[ldb])([a-zA-Z-\\.]+)?\\.(com|net|app|gift|ru|uk)`, "ig"); const okayDiscordRegex = new RegExp(`([a-zA-Z-\\.]+\\.)?discord((?:app)|(?:status))?\\.(com|net|app)`, "i"); const fakeSteamRegex = new RegExp(`str?e[ea]?mcomm?m?un[un]?[un]?[tl]?[il][tl]?ty\\.(com|net|ru|us)`, "ig"); -const sketchyRuRegex = new RegExp(`([a-zA-Z-\\.]+).ru.com`, "ig"); +// No `g` flag: this one is used with .test(), which advances lastIndex on a +// global regex and would make results alternate between messages. +const sketchyRuRegex = new RegExp(`([a-zA-Z-\\.]+).ru.com`, "i"); // TODO: consider de-duping with invitefilter event export default { @@ -62,26 +64,24 @@ export default { } } - const modlogId = current.modlog; - const modlogChannel = message.guild.channels.cache.get(modlogId!); - if (!modlogId || !modlogChannel || !modlogChannel.isTextBased()) return; // Can't log - - const dEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: message.author.username, iconURL: message.author.displayAvatarURL()}) - .setDescription(`Message sent by ${message.author.username} in ${message.channel.name}\n\n` + message.content) - .addFields({name: "Reason", value: reason}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - await modlogChannel.send({embeds: [dEmbed]}); - + await sendModLog(message.guild, current.modlog, { + heading: message.author.username, + iconUrl: message.author.displayAvatarURL(), + body: `Message sent by ${message.author.username} in ${message.channel.name}\n\n${message.content}`, + reason, + userId: message.author.id, + at: message.createdTimestamp + }); if (didMute) { - const mEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: "Member Muted", iconURL: message.author.displayAvatarURL()}) - .setDescription(`${message.author.displayName} ${message.author.tag}`) - .addFields({name: "Reason", value: reason}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - - await modlogChannel.send({embeds: [mEmbed]}); + await sendModLog(message.guild, current.modlog, { + heading: "Member Muted", + iconUrl: message.author.displayAvatarURL(), + body: `${message.author.displayName} ${message.author.tag}`, + reason, + userId: message.author.id, + at: message.createdTimestamp + }); } }, }; \ No newline at end of file diff --git a/src/events/developer.ts b/src/events/developer.ts index 6579da4..ddf8700 100644 --- a/src/events/developer.ts +++ b/src/events/developer.ts @@ -1,17 +1,18 @@ import {Events, GuildMember} from "discord.js"; +import config from "../config"; export default { name: Events.GuildMemberAdd, async execute(member: GuildMember) { - const bdGuild = await member.client.guilds.fetch("86004744966914048"); + const bdGuild = await member.client.guilds.fetch(config.guilds.betterDiscord); const bdMember = await bdGuild.members.fetch(member); if (!bdMember) return; - const isPluginDev = bdMember.roles.cache.has("125166040689803264"); - const isThemeDev = bdMember.roles.cache.has("165005972970930176"); - const rolesToAdd = [isPluginDev ? "948627723830591568" : "", isThemeDev ? "948627648706392104" : ""].filter(r => r); + const isPluginDev = bdMember.roles.cache.has(config.roles.pluginDeveloper); + const isThemeDev = bdMember.roles.cache.has(config.roles.themeDeveloper); + const rolesToAdd = [isPluginDev ? config.roles.communityPluginDeveloper : "", isThemeDev ? config.roles.communityThemeDeveloper : ""].filter(r => r); try { await member.roles.add(rolesToAdd, "Syncing roles from main server"); diff --git a/src/events/forwarding.ts b/src/events/forwarding.ts index 2236b76..a6f4bf6 100644 --- a/src/events/forwarding.ts +++ b/src/events/forwarding.ts @@ -1,4 +1,6 @@ -import {EmbedBuilder, Events, type Message} from "discord.js"; +import {Events, MessageFlags, type Message} from "discord.js"; +import {container, text} from "../framework"; +import {Accents} from "../util/colors"; import {globalDB} from "../db"; @@ -18,16 +20,18 @@ export default { const user = message.client.users.cache.get(target); if (!user) return; - const embed = new EmbedBuilder() - .setAuthor({name: `${message.author.displayName} (${message.author.id})`, iconURL: message.author.displayAvatarURL()}) - .setDescription(message.content ?? "\u200B"); + const lines = [ + `### ${message.author.displayName} (${message.author.id})`, + message.content || "\u200B" + ]; - if (message.attachments.size) { - for (const [id, att] of message.attachments) { - embed.addFields({name: att.name, value: `[${id}](${att.url})`}); - } + for (const [id, attachment] of message.attachments) { + lines.push(`**${attachment.name}** — [${id}](${attachment.url})`); } - await user.send({embeds: [embed]}); + await user.send({ + flags: MessageFlags.IsComponentsV2, + components: [container(lines.map(text), {accentColor: Accents.Info})] + }); }, }; \ No newline at end of file diff --git a/src/events/interaction.ts b/src/events/interaction.ts index 880bae3..b6893da 100644 --- a/src/events/interaction.ts +++ b/src/events/interaction.ts @@ -1,74 +1,15 @@ -import {MessageFlags, type ChatInputCommandInteraction, type Interaction} from "discord.js"; -import type {CommandStats} from "../types"; -import {statsDB} from "../db"; +import {Events} from "discord.js"; +import {defineEvent} from "../framework"; -export default { - name: "interactionCreate", +/** + * Routing lives in the dispatcher (`src/framework/dispatch.ts`), which is built + * once at startup so it can validate every command and component up front. + */ +export default defineEvent({ + name: Events.InteractionCreate, - async execute(interaction: Interaction) { - let commandName = ""; - let executor: "execute" | "autocomplete" | "button" | "modal" | "role" | "select" = "execute"; - - if (interaction.isChatInputCommand()) { - commandName = interaction.commandName; - executor = "execute"; - await this.addStat(interaction); - } - else if (interaction.isAutocomplete()) { - commandName = interaction.commandName; - executor = "autocomplete"; - } - else if (interaction.isButton()) { - executor = "button"; - commandName = interaction.customId.split("-")[0]; - } - else if (interaction.isModalSubmit()) { - executor = "modal"; - commandName = interaction.customId.split("-")[0]; - } - else if (interaction.isStringSelectMenu()) { - executor = "select"; - commandName = interaction.customId.split("-")[0]; - } - else if (interaction.isRoleSelectMenu()) { - executor = "role"; - commandName = interaction.customId.split("-")[0]; - } - - const command = interaction.client.commands.get(commandName); - if (!commandName || !command || !command[executor]) { - if (interaction.isChatInputCommand() && interaction.isRepliable()) { - console.error("Unrecognized interaction", commandName, executor); - await interaction.reply({content: "Something went wrong! If this persists, please report it to the bot owner!", flags: MessageFlags.Ephemeral}); - } - // TODO: maybe add a pino logger here - return; - } - - try { - await command[executor](interaction); - } - catch (error) { - console.error(error); - if (interaction.isRepliable()) await interaction.reply({content: "There was an error while executing this command!", flags: MessageFlags.Ephemeral}); - } - }, - - async addStat(interaction: ChatInputCommandInteraction) { - const key = interaction.guildId ?? interaction.client.user?.id; - const name = interaction.commandName; - - // More type-safe approach - const existingData = await statsDB.get(key) as CommandStats | undefined; - const data: CommandStats = existingData ?? {commands: {}}; - - // Ensure commands object exists - data.commands ??= {}; - - // Increment command count - data.commands[name] = (data.commands[name] ?? 0) + 1; - - await statsDB.set(key, data); + async execute(interaction) { + await interaction.client.dispatcher.dispatch(interaction); } -}; \ No newline at end of file +}); diff --git a/src/events/invitefilter.ts b/src/events/invitefilter.ts index 0b81074..b6f4ac4 100644 --- a/src/events/invitefilter.ts +++ b/src/events/invitefilter.ts @@ -1,6 +1,6 @@ -import {EmbedBuilder, Events, PermissionFlagsBits, type Message} from "discord.js"; +import {Events, PermissionFlagsBits, type Message} from "discord.js"; import {guildDB} from "../db"; -import Colors from "../util/colors"; +import {sendModLog} from "../util/modlog"; @@ -54,26 +54,25 @@ export default { } } - const modlogId = current.modlog; - const modlogChannel = message.guild.channels.cache.get(modlogId!); - if (!modlogId || !modlogChannel || !modlogChannel.isTextBased()) return; // Can't log - - const dEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: message.author.username, iconURL: message.author.displayAvatarURL()}) - .setDescription(`Message sent by ${message.author.username} in ${message.channel.name}\n\n` + message.content) - .addFields({name: "Reason", value: "Discord Invite"}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - await modlogChannel.send({embeds: [dEmbed]}); - + const reason = "Discord Invite"; + await sendModLog(message.guild, current.modlog, { + heading: message.author.username, + iconUrl: message.author.displayAvatarURL(), + body: `Message sent by ${message.author.username} in ${message.channel.name}\n\n${message.content}`, + reason, + userId: message.author.id, + at: message.createdTimestamp + }); if (didMute) { - const mEmbed = new EmbedBuilder().setColor(Colors.Info) - .setAuthor({name: "Member Muted", iconURL: message.author.displayAvatarURL()}) - .setDescription(`${message.author.displayName} ${message.author.tag}`) - .addFields({name: "Reason", value: "Discord Invite"}) - .setFooter({text: `ID: ${message.author.id}`}).setTimestamp(message.createdTimestamp); - - await modlogChannel.send({embeds: [mEmbed]}); + await sendModLog(message.guild, current.modlog, { + heading: "Member Muted", + iconUrl: message.author.displayAvatarURL(), + body: `${message.author.displayName} ${message.author.tag}`, + reason, + userId: message.author.id, + at: message.createdTimestamp + }); } }, }; \ No newline at end of file diff --git a/src/events/joinleave.ts b/src/events/joinleave.ts index 07596eb..6f58c73 100644 --- a/src/events/joinleave.ts +++ b/src/events/joinleave.ts @@ -1,6 +1,6 @@ import {Events, type GuildMember} from "discord.js"; import {guildDB} from "../db"; -import Messages from "../util/messages"; +import * as notices from "../util/notices"; @@ -14,7 +14,7 @@ export default [ if (!guildSettings || !guildSettings.joinleave) return; const channel = member.guild.channels.cache.get(guildSettings.joinleave); if (!channel || !channel.isTextBased()) return; - await channel.send(Messages.success(`<@!${member.user.id}> has joined the server!`)); + await channel.send(notices.success(`<@!${member.user.id}> has joined the server!`)); }, }, { @@ -25,7 +25,7 @@ export default [ if (!guildSettings || !guildSettings.joinleave) return; const channel = member.guild.channels.cache.get(guildSettings.joinleave); if (!channel || !channel.isTextBased()) return; - await channel.send(Messages.error(`**${member.user.tag} (${member.user.id})** has left the server!`)); + await channel.send(notices.error(`**${member.user.tag} (${member.user.id})** has left the server!`)); }, } ]; \ No newline at end of file diff --git a/src/framework/README.md b/src/framework/README.md new file mode 100644 index 0000000..f7c0dbb --- /dev/null +++ b/src/framework/README.md @@ -0,0 +1,109 @@ +# framework + +Command, component and event plumbing. Two mechanisms, chosen by **lifetime**. + +## Which one do I want? + +> Does this UI need to work after a bot restart, or more than 15 minutes after it +> was sent? + +**Yes → a registered component.** State cannot live in a closure, so it goes in +the custom id (and the database). Handled by the global dispatcher. + +**No → a session.** The UI belongs to one invocation by one user and dies with +the interaction token. State lives in a closure. Handled by its own collector. + +Using the wrong one is the usual source of "this button stopped working after a +deploy" (a session that should have been a component) and of duplicated +ownership/timeout logic (a component that should have been a session). + +## Commands + +```ts +export const command = defineCommand({ + guildOnly: true, // dispatcher checks inCachedGuild() for you + data: {type: ApplicationCommandType.ChatInput, name: "…", description: "…"}, + async execute(interaction) { // ChatInputCommandInteraction<"cached"> + … + } +}); + +export const components = [/* defineComponent(...) results */]; +``` + +`guildOnly` is a claim the dispatcher enforces *before* calling you, which is +what earns the `<"cached">` narrowing. Omit it and `interaction.guild` is +`null`-checked, as it should be. + +`ownerOnly: true` restricts to `BOT_OWNER_ID` and routes the command to the +private guild at deploy time. + +## Components (durable) + +```ts +const picker = defineComponent({ + id: "selfroles.open", // unique namespace, prefix of every id it mints + kind: "button", // fixes the interaction type + guildOnly: true, + params: {mode: oneOf("user", "admin")}, + + async run(interaction, {mode}) { // ButtonInteraction<"cached">, mode: "user" | "admin" + … + } +}); + +// emitting — checked in both directions +{type: ComponentType.Button, customId: picker.customId({mode: "admin"}), …} +``` + +`oneOf` yields a literal union, so a `switch` over the param can be exhaustive. +A missing param, a wrong type, or a typo in a literal is a compile error. + +Custom ids are capped at Discord's 100 characters; `customId()` throws if you +exceed it rather than letting the API reject the message. A stale id from before +a deploy fails to decode and the user is told to re-run the command. + +## Sessions (ephemeral) + +```ts +await runSession({ + interaction, + initial: 1, + render: (page, {ended}) => ({…}), // pure: state in, message out + reduce: (action, page) => action === "next" ? page + 1 : undefined, +}); +``` + +Controls use `sessionId("next")`, which carries a `~` prefix. The dispatcher +ignores those, so sessions and registered components share one custom-id space +without colliding. + +The ownership check, the timeout and disabling the controls when the collector +ends all happen inside `runSession` — do not reimplement them per command. +`audience: "anyone"` opts out of the ownership check. + +`awaitModal(interaction, modal, ["title", "content"])` is the one-shot version: +it returns `{submission, values}`, or `null` on timeout. + +## Events + +```ts +export default defineEvent({name: Events.MessageCreate, async execute(message) {…}}); +export default defineEvents( // a file may register several + {name: Events.GuildMemberAdd, async execute(member) {…}}, + {name: Events.GuildMemberRemove, async execute(member) {…}}, +); +``` + +## Migrating a command + +1. Replace the `SlashCommandBuilder` chain with a plain + `RESTPostAPIChatInputApplicationCommandsJSONBody` object. +2. `export const command = defineCommand({…})` instead of `export default {…}`. +3. Move each `button` / `modal` / `select` / `role` handler to its own + `defineComponent`, and export them as `components`. +4. Replace `customId.split("-")[n]` with typed `params`. + +Unmigrated commands keep working — the dispatcher has a legacy path that routes +them the old way. Delete `LegacyEntry` and friends from `dispatch.ts` once the +last command is converted. diff --git a/src/framework/dispatch.ts b/src/framework/dispatch.ts new file mode 100644 index 0000000..2353573 --- /dev/null +++ b/src/framework/dispatch.ts @@ -0,0 +1,168 @@ +/** + * The one place a raw Interaction becomes a typed handler call. + * + * Every unsafe narrowing in the app lives here, each on the line after the + * runtime check that justifies it. That is the point: not zero unsafety, but + * unsafety that is located, guarded and auditable. + * + */ + +import { + type ChatInputCommandInteraction, type Interaction, MessageFlags, type RepliableInteraction +} from "discord.js"; +import {IdError, namespaceOf} from "./ids"; +import type {Command, Component, ComponentKind} from "./registry"; +import {isSessionId} from "./session"; + + +const KIND_GUARD: {[K in ComponentKind]: (interaction: Interaction) => boolean} = { + button: interaction => interaction.isButton(), + stringSelect: interaction => interaction.isStringSelectMenu(), + roleSelect: interaction => interaction.isRoleSelectMenu(), + userSelect: interaction => interaction.isUserSelectMenu(), + channelSelect: interaction => interaction.isChannelSelectMenu(), + mentionableSelect: interaction => interaction.isMentionableSelectMenu(), + modal: interaction => interaction.isModalSubmit() +}; + + +export interface DispatcherOptions { + ownerId: string; + /** Called before a chat-input command runs. Used for command stats. */ + onCommandRun?(interaction: ChatInputCommandInteraction): Promise; +} + + +export class Dispatcher { + private commands = new Map(); + private components = new Map(); + private options: DispatcherOptions; + + constructor(options: DispatcherOptions) { + this.options = options; + } + + addCommand(command: Command): void { + const name = command.data.name; + if (this.commands.has(name)) throw new Error(`duplicate command "${name}"`); + this.commands.set(name, command); + } + + addComponent(component: Component): void { + if (this.components.has(component.id)) throw new Error(`duplicate component namespace "${component.id}"`); + this.components.set(component.id, component); + } + + get counts(): {commands: number; components: number;} { + return {commands: this.commands.size, components: this.components.size}; + } + + + async dispatch(interaction: Interaction): Promise { + try { + if (interaction.isChatInputCommand()) return await this.runCommand(interaction); + if (interaction.isAutocomplete()) return await this.runAutocomplete(interaction); + if (interaction.isMessageComponent() || interaction.isModalSubmit()) return await this.runComponent(interaction); + } + catch (error) { + await this.reportFailure(interaction, error); + } + } + + + private async runCommand(interaction: ChatInputCommandInteraction): Promise { + const command = this.commands.get(interaction.commandName); + if (!command) { + console.error("unregistered command", interaction.commandName); + return await this.reply(interaction, "That command isn't registered any more."); + } + + await this.options.onCommandRun?.(interaction); + if (!this.permitted(command, interaction)) return await this.reply(interaction, "You can't use that command here."); + + // Guarded above: `guildOnly` was checked, so the `<"cached">` the handler + // declares is actually true by this point. + await command.execute(interaction); + } + + + private async runAutocomplete(interaction: Interaction): Promise { + if (!interaction.isAutocomplete()) return; + + const command = this.commands.get(interaction.commandName); + if (!command?.autocomplete) return await interaction.respond([]); + if (command.guildOnly && !interaction.inCachedGuild()) return await interaction.respond([]); + + await command.autocomplete(interaction); + } + + + private async runComponent(interaction: Interaction): Promise { + if (!interaction.isMessageComponent() && !interaction.isModalSubmit()) return; + + // Session-owned. Its own collector handles it; this is the contract that + // lets registered components and sessions share one custom-id space. + if (isSessionId(interaction.customId)) return; + + // Unknown namespace: it belongs to a live session, whose own collector + // handles it. This silence is the contract that lets registered + // components and sessions share one custom-id space. + const component = this.components.get(namespaceOf(interaction.customId)); + if (!component) return; + + if (!KIND_GUARD[component.kind](interaction)) { + console.warn(`component "${component.id}" is registered as ${component.kind} but received a ${interaction.isModalSubmit() ? "modal submit" : "component"} interaction`); + return; + } + if (!this.permitted(component, interaction)) return await this.reply(interaction, "You can't use that."); + + let params; + try { + params = component.decode(interaction.customId); + } + catch (error) { + // Almost always a message from before the last deploy. + if (error instanceof IdError) { + console.warn("stale custom id", interaction.customId, error.message); + return await this.reply(interaction, "This message is out of date. Please run the command again."); + } + throw error; + } + + await component.run(interaction, params); + } + + + + private permitted(definition: {guildOnly?: boolean; ownerOnly?: boolean;}, interaction: Interaction): boolean { + if (definition.guildOnly && !interaction.inCachedGuild()) return false; + if (definition.ownerOnly && interaction.user.id !== this.options.ownerId) return false; + return true; + } + + + private async reply(interaction: Interaction, content: string): Promise { + if (!interaction.isRepliable()) return; + await this.send(interaction, content); + } + + + private async send(interaction: RepliableInteraction, content: string): Promise { + const payload = {content, flags: MessageFlags.Ephemeral} as const; + if (interaction.deferred || interaction.replied) await interaction.followUp(payload); + else await interaction.reply(payload); + } + + + private async reportFailure(interaction: Interaction, error: unknown): Promise { + console.error(error); + if (!interaction.isRepliable()) return; + try { + await this.send(interaction, "Something went wrong running that. It has been logged."); + } + catch (replyError) { + // The token may already be dead. The reporter must never throw. + console.error("could not report failure to user", replyError); + } + } +} diff --git a/src/framework/ids.ts b/src/framework/ids.ts new file mode 100644 index 0000000..416b8e2 --- /dev/null +++ b/src/framework/ids.ts @@ -0,0 +1,110 @@ +/** + * Typed custom IDs. + * + * Discord gives us one 100-character string to carry state from a component back + * to its handler. Parsing that string by hand means the code that mints the ID + * and the code that reads it have no contract. This makes it a typed one. + */ + +export interface ParamCodec { + parse(raw: string): T; + format(value: T): string; +} + +const SEP = ":"; + +/** Escaped so string params may contain the separator. */ +const escape = (value: string) => value.replace(/%/g, "%25").replace(/:/g, "%3A"); +const unescape = (value: string) => value.replace(/%3A/g, ":").replace(/%25/g, "%"); + +export class IdError extends Error {} + +export const Str: ParamCodec = { + parse: unescape, + format: escape +}; + +export const Num: ParamCodec = { + parse(raw) { + const value = Number(raw); + if (!Number.isFinite(value)) throw new IdError(`expected a number, got ${JSON.stringify(raw)}`); + return value; + }, + format: value => String(value) +}; + +export const Bool: ParamCodec = { + parse(raw) { + // Anything else is a stale or tampered id, and must fail like the other + // codecs rather than quietly decoding to false. + if (raw !== "0" && raw !== "1") throw new IdError(`expected a boolean, got ${JSON.stringify(raw)}`); + return raw === "1"; + }, + format: value => value ? "1" : "0" +}; + +/** A snowflake, validated on the way out and on the way back in. */ +export const Id: ParamCodec = { + parse(raw) { + if (!/^\d{15,25}$/.test(raw)) throw new IdError(`expected a snowflake, got ${JSON.stringify(raw)}`); + return raw; + }, + format(value) { + if (!/^\d{15,25}$/.test(value)) throw new IdError(`${JSON.stringify(value)} is not a snowflake`); + return value; + } +}; + +/** Produces a literal union, so a switch over the param can be exhaustive. */ +export function oneOf(...allowed: T): ParamCodec { + return { + parse(raw) { + if (!allowed.includes(raw)) throw new IdError(`expected one of ${allowed.join("|")}, got ${JSON.stringify(raw)}`); + return raw; + }, + format: value => value + }; +} + +/** + * `ParamCodec` is invariant in T (it both produces and consumes a T), so no + * single `ParamCodec` is a supertype of all codecs. This is the supertype: + * produces `unknown`, consumes `never`. Constraint position only — at each + * definition site the concrete codec types are still inferred. + */ +export interface AnyParamCodec { + parse(raw: string): unknown; + format(value: never): string; +} + +export type ParamSpec = Record; +export type Params = {[K in keyof S]: S[K] extends ParamCodec ? T : never}; + +/** Discord's hard limit on custom_id. Better to fail here than at send time. */ +export const MAX_CUSTOM_ID = 100; + +export function encodeId(namespace: string, spec: S, params: Params): string { + const values = params as Record; + const parts = [namespace]; + for (const key of Object.keys(spec)) parts.push(spec[key].format(values[key])); + + const id = parts.join(SEP); + if (id.length > MAX_CUSTOM_ID) { + throw new IdError(`custom id for "${namespace}" is ${id.length} chars (max ${MAX_CUSTOM_ID}). Store the payload and reference it by key instead.`); + } + return id; +} + +export function decodeId(spec: S, raw: string): Params { + const [, ...values] = raw.split(SEP); + const keys = Object.keys(spec); + if (values.length !== keys.length) { + throw new IdError(`expected ${keys.length} params, got ${values.length} in ${JSON.stringify(raw)}`); + } + + const parsed: Record = {}; + keys.forEach((key, index) => {parsed[key] = spec[key].parse(values[index]);}); + return parsed as Params; +} + +export const namespaceOf = (raw: string): string => raw.split(SEP, 1)[0]; diff --git a/src/framework/index.ts b/src/framework/index.ts new file mode 100644 index 0000000..a7604ef --- /dev/null +++ b/src/framework/index.ts @@ -0,0 +1,6 @@ +export * from "./ids"; +export * from "./registry"; +export * from "./session"; +export * from "./ui"; +export {Dispatcher} from "./dispatch"; +export {loadCommands, loadEvents} from "./loader"; diff --git a/src/framework/loader.ts b/src/framework/loader.ts new file mode 100644 index 0000000..5258565 --- /dev/null +++ b/src/framework/loader.ts @@ -0,0 +1,101 @@ +/** + * Module loading, with validation. + * + * The old loader cast the result of a dynamic `import()` straight to + * `CommandModule`, so a malformed module became a runtime mystery instead of a + * startup error. `events/joinleave.ts` exports an array of two listeners, which + * that cast turned into `client.on(undefined, …)` — a feature that has never + * fired. Everything here is checked, and a bad module fails loudly at boot. + */ + +import fs from "node:fs"; +import path from "node:path"; +import {pathToFileURL} from "node:url"; +import type {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; +import type {Dispatcher} from "./dispatch"; +import type {Command, Component, EventDef} from "./registry"; + + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; +const isFn = (value: unknown): value is (...args: never[]) => Promise => typeof value === "function"; + + +export interface LoadedCommand { + name: string; + data: RESTPostAPIChatInputApplicationCommandsJSONBody; + ownerOnly: boolean; + register(dispatcher: Dispatcher): void; +} + + +function sourceFiles(directory: string): string[] { + return fs.readdirSync(directory) + .filter(file => file.endsWith(".ts") || file.endsWith(".tsx")) + .map(file => path.join(directory, file)); +} + +async function importModule(file: string): Promise> { + const imported: unknown = await import(pathToFileURL(file).href); + if (!isRecord(imported)) throw new Error(`${path.basename(file)} did not export a module object`); + return imported; +} + + +function commandData(source: unknown, file: string): RESTPostAPIChatInputApplicationCommandsJSONBody { + if (!isRecord(source) || typeof source.name !== "string") { + throw new Error(`${path.basename(file)}: command data has no name`); + } + return source as unknown as RESTPostAPIChatInputApplicationCommandsJSONBody; +} + + +export async function loadCommands(directory: string): Promise { + const loaded: LoadedCommand[] = []; + + for (const file of sourceFiles(directory)) { + const module = await importModule(file); + + if (!isRecord(module.command)) { + throw new Error(`${path.basename(file)}: no exported command (expected \`export const command = defineCommand({...})\`)`); + } + + const command = module.command as unknown as Command; + if (typeof command.execute !== "function") throw new Error(`${path.basename(file)}: exported command has no execute()`); + + const components = Array.isArray(module.components) ? module.components as Component[] : []; + const data = commandData(command.data, file); + + loaded.push({ + name: data.name, + data, + ownerOnly: command.ownerOnly === true, + register(dispatcher) { + dispatcher.addCommand(command); + for (const component of components) dispatcher.addComponent(component); + } + }); + } + + return loaded.sort((a, b) => a.name.localeCompare(b.name)); +} + + +/** Accepts one listener or an array of them from a single file. */ +export async function loadEvents(directory: string): Promise { + const events: EventDef[] = []; + + for (const file of sourceFiles(directory)) { + const module = await importModule(file); + const exported: unknown = module.default ?? module.event ?? module.events; + const candidates: unknown[] = Array.isArray(exported) ? exported : [exported]; + + for (const candidate of candidates) { + if (!isRecord(candidate) || typeof candidate.name !== "string" || !isFn(candidate.execute)) { + throw new Error(`${path.basename(file)}: exported an event without a name and execute()`); + } + events.push(candidate as unknown as EventDef); + } + } + + return events; +} diff --git a/src/framework/registry.ts b/src/framework/registry.ts new file mode 100644 index 0000000..811a628 --- /dev/null +++ b/src/framework/registry.ts @@ -0,0 +1,101 @@ +/** + * Command, component and event definitions. + * + * Two rules this file exists to enforce: + * + * 1. A handler's interaction type is fixed by the definition, not chosen by the + * caller. The old `CommandModule` used ``, + * which puts T under the caller's control and makes every implementation's + * narrowing unchecked. + * 2. `guildOnly` is a claim the dispatcher enforces before calling you, not an + * `as` cast you assert afterwards. + */ + +import type { + AutocompleteInteraction, ButtonInteraction, CacheType, ChannelSelectMenuInteraction, + ChatInputCommandInteraction, ClientEvents, MentionableSelectMenuInteraction, + ModalSubmitInteraction, RESTPostAPIChatInputApplicationCommandsJSONBody, + RoleSelectMenuInteraction, StringSelectMenuInteraction, UserSelectMenuInteraction +} from "discord.js"; +import {decodeId, encodeId, type ParamSpec, type Params} from "./ids"; + + +/** `true` gives handlers `<"cached">` interactions. */ +export type Cache = G extends true ? "cached" : CacheType; + + +/* -------------------------------------------------------------------- commands */ + +export interface Command { + data: RESTPostAPIChatInputApplicationCommandsJSONBody; + /** Dispatcher rejects the interaction unless it is in a cached guild. */ + guildOnly?: G; + /** Dispatcher rejects the interaction unless the user is BOT_OWNER_ID. */ + ownerOnly?: boolean; + execute(interaction: ChatInputCommandInteraction>): Promise; + autocomplete?(interaction: AutocompleteInteraction>): Promise; +} + +export function defineCommand(command: Command): Command { + return command; +} + + +/* ------------------------------------------------------------------ components */ + +export interface ComponentInteractions { + button: ButtonInteraction>; + stringSelect: StringSelectMenuInteraction>; + roleSelect: RoleSelectMenuInteraction>; + userSelect: UserSelectMenuInteraction>; + channelSelect: ChannelSelectMenuInteraction>; + mentionableSelect: MentionableSelectMenuInteraction>; + modal: ModalSubmitInteraction>; +} + +export type ComponentKind = keyof ComponentInteractions; + +export interface ComponentDef { + /** Unique namespace, and the prefix of every custom id it mints. */ + id: string; + kind: K; + params: S; + guildOnly?: G; + ownerOnly?: boolean; + run(interaction: ComponentInteractions[K], params: Params): Promise; +} + +/** A registered definition: a handler and a type-checked id minter. */ +export interface Component extends ComponentDef { + customId(params: Params): string; + decode(raw: string): Params; +} + +export function defineComponent(definition: ComponentDef): Component { + return { + ...definition, + customId: params => encodeId(definition.id, definition.params, params), + decode: raw => decodeId(definition.params, raw) + }; +} + + +/* ---------------------------------------------------------------------- events */ + +export interface EventDef { + name: E; + once?: boolean; + execute(...args: ClientEvents[E]): Promise; +} + +export function defineEvent(event: EventDef): EventDef { + return event; +} + +/** + * For files that register more than one listener. The loader accepts an array + * from any event file, which is what `events/joinleave.ts` already assumed. + */ +export function defineEvents(...events: EventDef[]): EventDef[] { + return events; +} diff --git a/src/framework/session.ts b/src/framework/session.ts new file mode 100644 index 0000000..9d85475 --- /dev/null +++ b/src/framework/session.ts @@ -0,0 +1,122 @@ +/** + * Ephemeral, single-invocation UI. + * + * The other half of the interaction story. Registered components (registry.ts) + * are for UI that must survive a restart, so their state lives in the custom id. + * A session is for UI that belongs to one invocation by one user and dies with + * the interaction token, so its state lives in a closure. + * + * Written once so that the ownership check, the timeout, the disable-on-end and + * the error path are identical everywhere. + */ + +import { + MessageFlags, + type AwaitModalSubmitOptions, type InteractionEditReplyOptions, + type MessageComponentInteraction, type ModalComponentData, type ModalSubmitInteraction, + type RepliableInteraction +} from "discord.js"; +import {msInMinute} from "../util/time"; + + +const SESSION_PREFIX = "~"; + +/** + * Session-owned custom ids carry a prefix that can never be a registered + * namespace, so the global dispatcher knows to leave them to this collector. + */ +export const sessionId = (action: string): string => `${SESSION_PREFIX}${action}`; +export const isSessionId = (customId: string): boolean => customId.startsWith(SESSION_PREFIX); +const actionOf = (customId: string): string => customId.slice(SESSION_PREFIX.length); + + +export interface SessionOptions { + interaction: RepliableInteraction; + initial: S; + /** Pure: state in, message out. Called again after every accepted action. */ + render: (state: S, options: {ended: boolean;}) => InteractionEditReplyOptions; + /** + * Return the next state, or `undefined` to acknowledge without re-rendering. + * `action` is whatever was passed to `sessionId()`. + */ + reduce: (action: string, state: S, interaction: MessageComponentInteraction) => S | undefined | Promise; + timeout?: number; + /** Who may use the controls. Defaults to whoever ran the command. */ + audience?: "invoker" | "anyone"; +} + + +/** Resolves with the final state once the collector ends. */ +export async function runSession(options: SessionOptions): Promise { + const {interaction, render, reduce, timeout = msInMinute * 2, audience = "invoker"} = options; + let state = options.initial; + + // Always defer/editReply. Ephemerality is decided by how the caller defers, + // which is the only point at which Discord lets it be decided anyway. + if (!interaction.deferred && !interaction.replied) await interaction.deferReply(); + const message = await interaction.editReply(render(state, {ended: false})); + + const collector = message.createMessageComponentCollector({time: timeout}); + + return await new Promise(resolve => { + collector.on("collect", async componentInteraction => { + // The guard comes first. Nothing is captured before it passes. + if (audience === "invoker" && componentInteraction.user.id !== interaction.user.id) { + await componentInteraction.reply({ + content: "This menu belongs to someone else. Run the command yourself to get your own.", + flags: MessageFlags.Ephemeral + }); + return; + } + + try { + const next = await reduce(actionOf(componentInteraction.customId), state, componentInteraction); + if (next === undefined) { + if (!componentInteraction.replied && !componentInteraction.deferred) await componentInteraction.deferUpdate(); + return; + } + state = next; + await componentInteraction.update(render(state, {ended: false})); + } + catch (error) { + console.error("session action failed", error); + collector.stop("error"); + } + }); + + collector.on("end", async () => { + try { + // The single place that disables the controls. + await interaction.editReply(render(state, {ended: true})); + } + catch (error) { + console.error("could not finalise session", error); + } + resolve(state); + }); + }); +} + + +/** + * Show a modal and wait for it, with the fields already pulled out. + * Returns `null` on timeout so it cannot be confused with a real failure. + */ +export async function awaitModal( + interaction: RepliableInteraction, + modal: ModalComponentData, + fields: readonly F[], + options: AwaitModalSubmitOptions = {time: msInMinute * 5} +): Promise<{submission: ModalSubmitInteraction; values: Record;} | null> { + if (!interaction.isChatInputCommand() && !interaction.isMessageComponent()) return null; + await interaction.showModal(modal); + + try { + const submission = await interaction.awaitModalSubmit(options); + const values = Object.fromEntries(fields.map(field => [field, submission.fields.getTextInputValue(field)])) as Record; + return {submission, values}; + } + catch { + return null; + } +} diff --git a/src/framework/ui.ts b/src/framework/ui.ts new file mode 100644 index 0000000..df29f8c --- /dev/null +++ b/src/framework/ui.ts @@ -0,0 +1,48 @@ +/** + * Annotation helpers for plain component data. + * + * When an array mixes component shapes, TypeScript infers a union of object + * literals, fails to match a branch of discord.js's `components` union, and + * falls through to the snake_case API branch with an unreadable error. Pinning + * the element type fixes it. + * + * These are annotations, not casts. Everything inside stays checked. + */ + +import { + ComponentType, + type ActionRowData, type ComponentInContainerData, type ContainerComponentData, + type MessageActionRowComponentData, type TopLevelComponentData +} from "discord.js"; + + +/** + * A Components V2 message payload, accepted by reply / editReply / followUp / + * update / send alike. + * + * `flags` is deliberately `number` rather than the MessageFlags enum. An + * unannotated `MessageFlags.IsComponentsV2` widens to the whole enum, which is + * not assignable to the narrower per-method flag unions discord.js declares; + * `number` is assignable to a numeric enum and so satisfies all of them. + */ +export interface ComponentMessage { + flags: number; + components: TopLevelComponentData[]; +} + + +export const row = (...components: MessageActionRowComponentData[]): ActionRowData => ({ + type: ComponentType.ActionRow, + components +}); + +export const container = (components: ComponentInContainerData[], options: Omit = {}): ContainerComponentData => ({ + type: ComponentType.Container, + components, + ...options +}); + +export const text = (content: string): ComponentInContainerData => ({ + type: ComponentType.TextDisplay, + content +}); diff --git a/src/index.ts b/src/index.ts index 26b4ca8..a6aa271 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ -import fs from "node:fs"; import path from "node:path"; -import {ActivityType, Client, Collection, GatewayIntentBits, Partials} from "discord.js"; -import type {CommandModule, EventModule} from "./types"; -import {pathToFileURL} from "node:url"; +import {fileURLToPath} from "node:url"; +import {ActivityType, Client, GatewayIntentBits, Partials} from "discord.js"; +import {Dispatcher, loadCommands, loadEvents} from "./framework"; +import {recordCommandRun} from "./util/stats"; +const here = path.dirname(fileURLToPath(import.meta.url)); + // Create a new client instance const client = new Client({ intents: [ @@ -22,37 +24,29 @@ const client = new Client({ }); -client.commands = new Collection(); -const commandsPath = path.join(__dirname, "commands"); -const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith(".ts") || file.endsWith(".tsx")); +// Build the dispatcher up front so a malformed command or a duplicate component +// namespace fails at startup rather than on the first interaction. +const dispatcher = new Dispatcher({ + ownerId: process.env.BOT_OWNER_ID!, + onCommandRun: recordCommandRun +}); -for (const file of commandFiles) { - const filePath = path.join(commandsPath, file); - const command = await import(pathToFileURL(filePath).href) as {default: CommandModule;}; +const commands = await loadCommands(path.join(here, "commands")); +for (const command of commands) command.register(dispatcher); - // Handle both default and named exports - const commandData = "default" in command ? command.default : command; +const {commands: commandCount, components: componentCount} = dispatcher.counts; +console.log(`Loaded ${commandCount} commands and ${componentCount} components.`); - // Set a new item in the Collection - // With the key as the command name and the value as the exported module - client.commands.set(commandData.data.name, commandData); -} +client.dispatcher = dispatcher; -const eventsPath = path.join(__dirname, "events"); -const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith(".ts") || file.endsWith(".tsx")); - -for (const file of eventFiles) { - const filePath = path.join(eventsPath, file); - const event = await import(pathToFileURL(filePath).href) as {default: EventModule;}; - // Handle both default and named exports - const eventData = event.default || event; - if (eventData.once) { - client.once(eventData.name, (...args: Parameters) => eventData.execute(...args)); - } - else { - client.on(eventData.name, (...args: Parameters) => eventData.execute(...args)); - } + +const events = await loadEvents(path.join(here, "events")); +for (const event of events) { + if (event.once) client.once(event.name, (...args) => void event.execute(...args)); + else client.on(event.name, (...args) => void event.execute(...args)); } +console.log(`Registered ${events.length} event listeners.`); + // Login to Discord with your client's token await client.login(process.env.BOT_TOKEN); diff --git a/src/paginator.ts b/src/paginator.ts index c981917..5dd3d7a 100644 --- a/src/paginator.ts +++ b/src/paginator.ts @@ -1,116 +1,68 @@ -import {ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, ButtonInteraction, type JSONEncodable, type APIMessageTopLevelComponent, MessageFlags} from "discord.js"; -import {msInMinute} from "./util/time"; - - -interface PaginatorOptions { - interaction: CommandInteraction; +/** + * Button pagination, built on `runSession`. + * + * The ownership check, the timeout and disabling the controls when the collector + * ends are no longer this file's concern — they happen once, in + * `src/framework/session.ts`, which is why the previous version's two bugs + * (capturing the button interaction before the user check, and dropping + * `IsComponentsV2` on the final edit) are no longer expressible here. + */ + +import { + ButtonStyle, ComponentType, MessageFlags, + type InteractionEditReplyOptions, type MessageActionRowComponentData, type RepliableInteraction +} from "discord.js"; +import {row, runSession, sessionId} from "./framework"; + + +type PageComponent = NonNullable[number]; + +export interface PaginateOptions { + interaction: RepliableInteraction; items: T[]; - renderPage: (items: T[], page?: number, totalPages?: number) => JSONEncodable | Array>; - itemsPerPage?: number; + /** Top-level components for one page. Controls are appended automatically. */ + renderPage: (items: T[], page: number, pages: number) => readonly PageComponent[]; + perPage?: number; timeout?: number; + audience?: "invoker" | "anyone"; } -export default class Paginator { - private interaction: CommandInteraction; - private entries: T[]; - private itemsPerPage: number; - private timeout: number; - private renderPage: PaginatorOptions["renderPage"]; - private pages: Array | Array>> = []; - - private numPages: number; - private currentPage: number = 1; - private buttonInteraction?: ButtonInteraction; - - constructor(options: PaginatorOptions) { - this.interaction = options.interaction; - this.entries = options.items; - this.itemsPerPage = options.itemsPerPage || 10; - this.timeout = options.timeout || msInMinute * 2; - this.renderPage = options.renderPage; - this.numPages = Math.floor(this.entries.length / this.itemsPerPage); - if (this.entries.length % this.itemsPerPage) this.numPages = this.numPages + 1; - for (let i = 1; i <= this.numPages; i++) { - const pageEntries = this.getEntriesForPage(i); - this.pages.push(this.renderPage(pageEntries, i, this.numPages)); +export async function paginate(options: PaginateOptions): Promise { + const {interaction, items, renderPage, perPage = 10, timeout, audience} = options; + const pages = Math.max(1, Math.ceil(items.length / perPage)); + + const controls = (page: number, ended: boolean): MessageActionRowComponentData[] => [ + {type: ComponentType.Button, customId: sessionId("first"), label: "<< First", style: ButtonStyle.Secondary, disabled: ended || page === 1}, + {type: ComponentType.Button, customId: sessionId("previous"), label: "< Previous", style: ButtonStyle.Primary, disabled: ended || page === 1}, + {type: ComponentType.Button, customId: sessionId("info"), label: `Page ${page} of ${pages}`, style: ButtonStyle.Secondary, disabled: true}, + {type: ComponentType.Button, customId: sessionId("next"), label: "Next >", style: ButtonStyle.Primary, disabled: ended || page === pages}, + {type: ComponentType.Button, customId: sessionId("last"), label: "Last >>", style: ButtonStyle.Secondary, disabled: ended || page === pages} + ]; + + await runSession({ + interaction, + initial: 1, + timeout, + audience, + + render: (page, {ended}) => ({ + // Set on every render, including the final one. + flags: MessageFlags.IsComponentsV2, + components: [ + ...renderPage(items.slice((page - 1) * perPage, page * perPage), page, pages), + row(...controls(page, ended)) + ] + }), + + reduce(action, page) { + switch (action) { + case "first": return 1; + case "previous": return Math.max(1, page - 1); + case "next": return Math.min(pages, page + 1); + case "last": return pages; + default: return undefined; + } } - } - - get buttons() { - return new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId("first") - .setLabel("<< First") - .setStyle(ButtonStyle.Secondary) - .setDisabled(this.currentPage === 1), - new ButtonBuilder() - .setCustomId("previous") - .setLabel("< Previous") - .setStyle(ButtonStyle.Primary) - .setDisabled(this.currentPage === 1), - new ButtonBuilder() - .setCustomId("page-info") - .setLabel(`Page ${this.currentPage} of ${this.numPages}`) - .setStyle(ButtonStyle.Secondary) - .setDisabled(true), - new ButtonBuilder() - .setCustomId("next") - .setLabel("Next >") - .setStyle(ButtonStyle.Primary) - .setDisabled(this.currentPage === this.numPages), - new ButtonBuilder() - .setCustomId("last") - .setLabel("Last >>") - .setStyle(ButtonStyle.Secondary) - .setDisabled(this.currentPage === this.numPages), - ); - } - - getEntriesForPage(page: number): T[] { - const base = (page - 1) * this.itemsPerPage; - return this.entries.slice(base, base + this.itemsPerPage); - } - - async firstPage() {await this.showPage(1);} - async lastPage() {await this.showPage(this.numPages);} - async nextPage() {await this.validatedShowPage(this.currentPage + 1);} - async previousPage() {await this.validatedShowPage(this.currentPage - 1);} - async validatedShowPage(page: number) { - if (page > 0 && page <= this.numPages) await this.showPage(page); - } - - async showPage(page: number) { - this.currentPage = page; - - const renderedPage = this.pages[this.currentPage - 1]; - const componentList = Array.isArray(renderedPage) ? renderedPage : [renderedPage]; - - if (this.buttonInteraction) return await this.buttonInteraction.update({components: [...componentList, this.buttons], flags: MessageFlags.IsComponentsV2}); - await this.interaction.editReply({components: [...componentList, this.buttons], flags: MessageFlags.IsComponentsV2}); - } - - async paginate() { - await this.showPage(1); - - const msg = await this.interaction.fetchReply(); - const collector = msg.createMessageComponentCollector({time: this.timeout}); - - collector.on("collect", async i => { - this.buttonInteraction = i as ButtonInteraction; - if (i.user.id !== this.interaction.user.id) return await i.reply({content: "You cannot interact with this menu.", flags: MessageFlags.Ephemeral}); - if (i.customId === "first") await this.firstPage(); - if (i.customId === "last") await this.lastPage(); - if (i.customId === "previous") await this.previousPage(); - if (i.customId === "next") await this.nextPage(); - if (i.customId === "page-info") await i.reply({content: `You are on page ${this.currentPage} of ${this.numPages}.`, flags: MessageFlags.Ephemeral}); - }); - - collector.on("end", async () => { - const renderedPage = this.pages[this.currentPage - 1]; - const componentList = Array.isArray(renderedPage) ? renderedPage : [renderedPage]; - await this.interaction.editReply({components: componentList}); - }); - } + }); } diff --git a/src/types/base.ts b/src/types/base.ts index f39d3bb..d9eabc6 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -1,34 +1,17 @@ // src/types.ts -import {AutocompleteInteraction, BaseInteraction, ButtonInteraction, ChatInputCommandInteraction, Collection, ModalSubmitInteraction, RoleSelectMenuInteraction, SlashCommandBuilder, StringSelectMenuInteraction} from "discord.js"; +import type {Dispatcher} from "../framework/dispatch"; // Extend the Discord.js Client interface globally declare module "discord.js" { interface Client { cpuUsage: NodeJS.CpuUsage; - commands: Collection; + dispatcher: Dispatcher; } } export type AtLeast = Partial & Pick; -export type CommandModule = { - data: SlashCommandBuilder | ReturnType; - owner?: boolean; - execute: (interaction: T) => Promise; - autocomplete: (i: T) => unknown; - button: (i: T) => unknown; - modal: (i: T) => unknown; - select: (i: T) => unknown; - role: (i: T) => unknown; -}; - -export interface EventModule { - name: string; - once?: boolean; - execute: (...args: unknown[]) => Promise; -} - export interface CommandStats { commands?: { [key: string]: number; diff --git a/src/util/addons.ts b/src/util/addons.ts index dff295b..6ad5392 100644 --- a/src/util/addons.ts +++ b/src/util/addons.ts @@ -1,4 +1,11 @@ -import {ActionRowBuilder, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, ContainerBuilder, MessageFlags, SectionBuilder, SeparatorBuilder, SeparatorSpacingSize, StringSelectMenuBuilder, StringSelectMenuInteraction, StringSelectMenuOptionBuilder, TextDisplayBuilder, ThumbnailBuilder} from "discord.js"; +import { + ButtonStyle, ComponentType, MessageFlags, SeparatorSpacingSize, + type ActionRowData, type ComponentInContainerData, type ContainerComponentData, + type MessageActionRowComponentData, type RepliableInteraction, type SectionComponentData, + type TextDisplayComponentData +} from "discord.js"; +import {container, row, runSession, sessionId, text} from "../framework"; +import * as notices from "./notices"; import type {BdWebAddon} from "../types"; import Web from "../util/web"; @@ -8,40 +15,59 @@ import {msInHour, msInMinute} from "./time"; export const cache = new Set(); -export async function ensureCache() { - const previousCacheUpdate = await globalDB.get("addonCacheLastUpdate") as number ?? 0; - if ((Date.now() - previousCacheUpdate) < msInHour) { - if (cache.size) return; - console.log("Loading addon cache from storage..."); - const storedCache = await globalDB.get("addonCache") as BdWebAddon[] ?? []; - for (const addon of storedCache) { - cache.add(addon); - } - return; - } - console.log(cache.size ? "Refreshing" : "Building", "addon cache..."); - await globalDB.set("addonCacheLastUpdate", Date.now()); - // Clear previous cache in DB and in-memory - await globalDB.set("addonCache", []); - cache.clear(); +/** De-duplicates concurrent refreshes so two commands don't both hit the store. */ +let inFlight: Promise | null = null; - let res = await request(Web.store.plugins); - let data = await res.body.json() as BdWebAddon[]; - for (const addon of data) { +async function loadFromStorage(): Promise { + console.log("Loading addon cache from storage..."); + const storedCache = await globalDB.get("addonCache") as BdWebAddon[] ?? []; + for (const addon of storedCache) { cache.add(addon); } +} + +async function refreshFromStore(): Promise { + console.log(cache.size ? "Refreshing" : "Building", "addon cache..."); + + // Fetch everything before touching what we already have. The previous + // version cleared the cache and stamped the timestamp up front, so a failed + // request left an empty cache that would not retry for an hour. + const fetched: BdWebAddon[] = []; + for (const url of [Web.store.plugins, Web.store.themes]) { + const res = await request(url); + fetched.push(...await res.body.json() as BdWebAddon[]); + } - res = await request(Web.store.themes); - data = await res.body.json() as BdWebAddon[]; - for (const addon of data) { + cache.clear(); + for (const addon of fetched) { cache.add(addon); } - await globalDB.set("addonCache", Array.from(cache)); + await globalDB.set("addonCache", fetched); + await globalDB.set("addonCacheLastUpdate", Date.now()); console.log(`Cached ${cache.size} addons from store.`); } +export async function ensureCache() { + const previousCacheUpdate = await globalDB.get("addonCacheLastUpdate") as number ?? 0; + if ((Date.now() - previousCacheUpdate) < msInHour) { + if (cache.size) return; + return await loadFromStorage(); + } + + try { + inFlight ??= refreshFromStore().finally(() => {inFlight = null;}); + await inFlight; + } + catch (error) { + // The timestamp was not advanced, so the next call retries. Serve + // whatever we have rather than failing the command outright. + console.error("Could not refresh addon cache:", error); + if (!cache.size) await loadFromStorage(); + } +} + export function sortAddons(addons: BdWebAddon[], sortBy: "likes" | "downloads" | "initial_release_date" | "latest_release_date"): BdWebAddon[] { return addons.sort((a, b) => { @@ -53,111 +79,128 @@ export function sortAddons(addons: BdWebAddon[], sortBy: "likes" | "downloads" | } -export function createAddonComponent(addon: BdWebAddon) { +const separator = (spacing: SeparatorSpacingSize, divider: boolean): ComponentInContainerData => + ({type: ComponentType.Separator, spacing, divider}); + +const thumbnail = (url: string) => ({type: ComponentType.Thumbnail as const, media: {url}}); - const buttons = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("View Online") - .setURL(Web.pages[addon.type](addon.name)), - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Download Now") - .setURL(Web.redirects.download(addon.id.toString())), - ); +/** The link buttons every addon carries, plus a support server when there is one. */ +function addonLinks(addon: BdWebAddon): MessageActionRowComponentData[] { + const buttons: MessageActionRowComponentData[] = [ + {type: ComponentType.Button, style: ButtonStyle.Link, label: "View Online", url: Web.pages[addon.type](addon.name)}, + {type: ComponentType.Button, style: ButtonStyle.Link, label: "Download Now", url: Web.redirects.download(addon.id.toString())} + ]; if (addon.author.guild?.invite_link) { - buttons.addComponents( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Support Server") - .setURL(addon.author.guild.invite_link), - ); + buttons.push({type: ComponentType.Button, style: ButtonStyle.Link, label: "Support Server", url: addon.author.guild.invite_link}); } - const page = new ContainerBuilder() - .addSectionComponents( - new SectionBuilder() - .setThumbnailAccessory( - new ThumbnailBuilder().setURL(Web.resources.thumbnail(addon.thumbnail_url)) - ) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent(`# ${addon.name} v${addon.version}`), - new TextDisplayBuilder().setContent(addon.description ?? "No description provided."), - new TextDisplayBuilder().setContent(addon.tags.map(tag => `\`${tag}\``).join(" ")), - ), - ) - .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small).setDivider(false)) - .addTextDisplayComponents(new TextDisplayBuilder().setContent(`👍 ${addon.likes.toLocaleString()} Likes ⬇️ ${addon.downloads.toLocaleString()} Downloads`)) - .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large).setDivider(true)) - .addActionRowComponents(buttons) - .addTextDisplayComponents(new TextDisplayBuilder().setContent(`-# Updated ${new Date(addon.latest_release_date).toLocaleDateString()} • Released ${new Date(addon.initial_release_date).toLocaleDateString()}`)); - - return page; + return buttons; } -export function createAddonSection(addon: BdWebAddon) { +/** One addon rendered in full, as its own page. */ +export function createAddonComponent(addon: BdWebAddon): ContainerComponentData { + const details: TextDisplayComponentData[] = [ + {type: ComponentType.TextDisplay, content: `# ${addon.name} v${addon.version}`}, + {type: ComponentType.TextDisplay, content: addon.description ?? "No description provided."}, + {type: ComponentType.TextDisplay, content: addon.tags.map(tag => `\`${tag}\``).join(" ")} + ]; + + return container([ + { + type: ComponentType.Section, + components: details, + accessory: thumbnail(Web.resources.thumbnail(addon.thumbnail_url)) + }, + separator(SeparatorSpacingSize.Small, false), + text(`👍 ${addon.likes.toLocaleString()} Likes ⬇️ ${addon.downloads.toLocaleString()} Downloads`), + separator(SeparatorSpacingSize.Large, true), + row(...addonLinks(addon)), + text(`-# Updated ${new Date(addon.latest_release_date).toLocaleDateString()} • Released ${new Date(addon.initial_release_date).toLocaleDateString()}`) + ]); +} + + +/** One addon as a compact row within a list. */ +export function createAddonSection(addon: BdWebAddon): SectionComponentData { const links = [ `[View Online](${Web.pages[addon.type](addon.name)})`, `[Download Now](${Web.redirects.download(addon.id.toString())})`, - addon.author.guild?.invite_link && `[Support Server](${addon.author.guild?.invite_link})` + addon.author.guild?.invite_link && `[Support Server](${addon.author.guild.invite_link})` ].filter(Boolean).join(" • "); - const section = new SectionBuilder() - .setThumbnailAccessory(new ThumbnailBuilder().setURL(Web.resources.thumbnail(addon.thumbnail_url))) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent(`### ${addon.name}`), - new TextDisplayBuilder().setContent(addon.description ?? "No description provided."), - new TextDisplayBuilder().setContent(links), - ); - - return section; + return { + type: ComponentType.Section, + components: [ + {type: ComponentType.TextDisplay, content: `### ${addon.name}`}, + {type: ComponentType.TextDisplay, content: addon.description ?? "No description provided."}, + {type: ComponentType.TextDisplay, content: links} + ], + accessory: thumbnail(Web.resources.thumbnail(addon.thumbnail_url)) + }; } -export function createAddonList(title: string, addons: BdWebAddon[]) { - const page = new ContainerBuilder(); + +export function createAddonList(title: string, addons: BdWebAddon[]): [TextDisplayComponentData, ContainerComponentData] { + const body: ComponentInContainerData[] = []; for (const [index, addon] of addons.entries()) { - page.addSectionComponents(createAddonSection(addon)); - if (index < addons.length - 1) page.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large).setDivider(true)); + body.push(createAddonSection(addon)); + if (index < addons.length - 1) body.push(separator(SeparatorSpacingSize.Large, true)); } - return [new TextDisplayBuilder().setContent(`## ${title}`), page]; + + return [{type: ComponentType.TextDisplay, content: `## ${title}`}, container(body)]; } -export function createNavigation(addons: BdWebAddon[], selectedIndex = 0, disabled = false) { - const navigation = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder().setCustomId(`addons-navigation`).addOptions( - ...addons.map((addon, index) => new StringSelectMenuOptionBuilder().setLabel(`${index + 1}. ${addon.name}`).setValue(addon.name).setDefault(index === selectedIndex)) - ) - .setDisabled(disabled) - ); - return navigation; + +/** The select menu action, as carried in its session-owned custom id. */ +const NAVIGATE = "addons-navigate"; + +export function createNavigation(addons: BdWebAddon[], selectedIndex = 0, disabled = false): ActionRowData { + return row({ + type: ComponentType.StringSelect, + customId: sessionId(NAVIGATE), + disabled, + options: addons.map((addon, index) => ({ + "label": `${index + 1}. ${addon.name}`, + "value": addon.name, + "default": index === selectedIndex + })) + }); } -export async function paginateAddonPages(interaction: ChatInputCommandInteraction<"cached">, addons: BdWebAddon[]) { - const navigation = createNavigation(addons); - const pages = addons.map(addon => createAddonComponent(addon)); +/** + * An addon browser: a select menu that swaps which addon is shown. + * + * Built on runSession, so the ownership check, the timeout and disabling the + * menu when it expires are the framework's job rather than this file's. + */ +export async function paginateAddonPages(interaction: RepliableInteraction, addons: BdWebAddon[], emptyMessage = "No addons matched.") { + // A select menu needs between 1 and 25 options; Discord rejects an empty + // one, so an empty result set has to be answered rather than rendered. + if (!addons.length) { + await interaction.editReply(notices.info(emptyMessage)); + return; + } - const msg = await interaction.fetchReply(); - const collector = msg.createMessageComponentCollector({time: 5 * msInMinute}); + await runSession({ + interaction, + initial: 0, + timeout: 5 * msInMinute, - let selectedIndex = 0; - collector.on("collect", async (i: StringSelectMenuInteraction<"cached">) => { - if (i.user.id !== interaction.user.id) return await i.reply({content: "You cannot interact with this menu.", flags: MessageFlags.Ephemeral}); + render: (selectedIndex, {ended}) => ({ + flags: MessageFlags.IsComponentsV2, + components: [createNavigation(addons, selectedIndex, ended), createAddonComponent(addons[selectedIndex])] + }), - const selectedAddonName = i.values[0]; - const selectedAddon = addons.find(a => a.name === selectedAddonName)!; - selectedIndex = addons.indexOf(selectedAddon); - const newPage = pages[selectedIndex]; - const newNavigation = createNavigation(addons, selectedIndex); - await i.update({components: [newNavigation, newPage], flags: MessageFlags.IsComponentsV2}); - }); + reduce(action, _selectedIndex, component) { + if (action !== NAVIGATE || !component.isStringSelectMenu()) return undefined; - collector.on("end", async () => { - await interaction.editReply({components: [createNavigation(addons, selectedIndex, true), pages[selectedIndex]], flags: MessageFlags.IsComponentsV2}); + // An unrecognised value leaves the state alone instead of throwing; + // the previous version asserted the lookup could not fail. + const next = addons.findIndex(addon => addon.name === component.values[0]); + return next === -1 ? undefined : next; + } }); - - await interaction.editReply({components: [navigation, pages[0]], flags: MessageFlags.IsComponentsV2}); -} \ No newline at end of file +} diff --git a/src/util/colors.ts b/src/util/colors.ts index 1bfc8e8..2c694c2 100644 --- a/src/util/colors.ts +++ b/src/util/colors.ts @@ -1,9 +1,21 @@ import type {HexColorString} from "discord.js"; +/** Authored as hex for embeds. */ export default class Colors { static Info: HexColorString = "#5a88ce"; static Warn: HexColorString = "#fbbf24"; static Success: HexColorString = "#3ac172"; static Danger: HexColorString = "#c13a3a"; static Error: HexColorString = "#c13a3a"; -} \ No newline at end of file +} + +const toInt = (hex: HexColorString): number => parseInt(hex.slice(1), 16); + +/** The same palette as integers, which Components V2 container accents want. */ +export const Accents = { + Info: toInt(Colors.Info), + Warn: toInt(Colors.Warn), + Success: toInt(Colors.Success), + Danger: toInt(Colors.Danger), + Error: toInt(Colors.Error) +} as const; diff --git a/src/util/messages.ts b/src/util/messages.ts deleted file mode 100644 index 9e93c4e..0000000 --- a/src/util/messages.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {EmbedBuilder, MessageFlags, type BaseMessageOptions, type ColorResolvable} from "discord.js"; -import Colors from "./colors"; - - -interface EmbedOptions { - description: string; - color: ColorResolvable; - ephemeral?: boolean; - components?: BaseMessageOptions["components"]; -} - -export default class Messages { - static embed({description, color, ephemeral, components}: EmbedOptions) { - // return new EmbedBuilder().setColor(color).setDescription(description); - const embed = new EmbedBuilder().setColor(color).setDescription(description); - const data: {embeds: EmbedBuilder[], components?: BaseMessageOptions["components"], flags?: number;} = {embeds: [embed], components}; - if (ephemeral) data.flags = MessageFlags.Ephemeral; - return data; - } - - static success(description: string, {ephemeral, components}: Partial> = {}) { - return this.embed({color: Colors.Success, description, ephemeral, components}); - } - - static error(description: string, {ephemeral, components}: Partial> = {}) { - return this.embed({color: Colors.Error, description, ephemeral, components}); - } - - static info(description: string, {ephemeral, components}: Partial> = {}) { - return this.embed({color: Colors.Info, description, ephemeral, components}); - } - - static warn(description: string, {ephemeral, components}: Partial> = {}) { - return this.embed({color: Colors.Warn, description, ephemeral, components}); - } -} \ No newline at end of file diff --git a/src/util/modlog.ts b/src/util/modlog.ts new file mode 100644 index 0000000..df4c382 --- /dev/null +++ b/src/util/modlog.ts @@ -0,0 +1,55 @@ +/** + * Moderation log entries. + * + * detectspam, invitefilter and detectcryptoscam each built their own + * near-identical embeds and each repeated the same channel-resolution dance. + * One helper now covers both entry shapes and the lookup. + */ + +import {ComponentType, MessageFlags, type ComponentInContainerData, type Guild, type TextDisplayComponentData} from "discord.js"; +import {container, text, type ComponentMessage} from "../framework"; +import {Accents} from "./colors"; + + +export interface ModLogEntry { + /** Heading line: the offending user, or the action taken. */ + heading: string; + /** Avatar shown alongside the entry, as the embed author icon used to be. */ + iconUrl?: string; + body: string; + reason: string; + userId: string; + /** Milliseconds; rendered as Discord's own per-viewer localised timestamp. */ + at: number; +} + + +export function modLogMessage(entry: ModLogEntry): ComponentMessage { + const lines: TextDisplayComponentData[] = [ + {type: ComponentType.TextDisplay, content: `### ${entry.heading}`}, + {type: ComponentType.TextDisplay, content: entry.body || "​"}, + {type: ComponentType.TextDisplay, content: `**Reason:** ${entry.reason}`} + ]; + + // A Section with a thumbnail is the closest V2 has to an embed author icon. + const body: ComponentInContainerData[] = entry.iconUrl + ? [{type: ComponentType.Section, components: lines, accessory: {type: ComponentType.Thumbnail, media: {url: entry.iconUrl}}}] + : [...lines]; + + body.push(text(`-# ID: ${entry.userId} • `)); + + return { + flags: MessageFlags.IsComponentsV2, + components: [container(body, {accentColor: Accents.Info})] + }; +} + + +/** Posts to the guild's configured modlog channel. Silently no-ops if unset. */ +export async function sendModLog(guild: Guild, channelId: string | undefined, entry: ModLogEntry): Promise { + if (!channelId) return; + const channel = guild.channels.cache.get(channelId); + if (!channel?.isTextBased()) return; + + await channel.send(modLogMessage(entry)); +} diff --git a/src/util/names.ts b/src/util/names.ts new file mode 100644 index 0000000..fd422cb --- /dev/null +++ b/src/util/names.ts @@ -0,0 +1,16 @@ +/** + * Display-name hygiene, shared by the `cleanname` command and its join handler. + * Previously duplicated in both, with a `TODO` acknowledging it. + */ + +/** + * Anything outside Discord's username standards. + * + * Deliberately NOT global. `RegExp.prototype.test` advances `lastIndex` on a + * `/g` regex and resumes from there on the next call, so a shared global regex + * returns alternating results across calls. That made `/cleanname server` skip + * a share of the members it should have renamed on every run. + */ +const disallowedChars = /[^A-Za-z0-9\-_\\. ]/; + +export const hasDisallowedChars = (displayName: string): boolean => disallowedChars.test(displayName); diff --git a/src/util/notices.ts b/src/util/notices.ts new file mode 100644 index 0000000..ccb738f --- /dev/null +++ b/src/util/notices.ts @@ -0,0 +1,63 @@ +/** + * Short status messages, as Components V2 containers. + * + * These replace the `` / `` / `` / `` JSX widgets + * from djsx and produce the same payload. + * + * This is how the bot sends a short status message. The one deliberate + * exception is `/about`, which keeps an embed because its stats are inline + * fields three to a row and Components V2 has no field grid. + */ + +import { + MessageFlags, + type ActionRowData, type ComponentInContainerData, type MessageActionRowComponentData +} from "discord.js"; +import {container, text, type ComponentMessage} from "../framework/ui"; +import {Accents} from "./colors"; + + +export type NoticeKind = "success" | "info" | "warn" | "error" | "danger"; + +export interface NoticeOptions { + ephemeral?: boolean; + /** Extra message flags to merge in. */ + flags?: number; + /** Action rows rendered inside the container, below the text. */ + components?: Array>; +} + +const ACCENTS: Record = { + success: Accents.Success, + info: Accents.Info, + warn: Accents.Warn, + error: Accents.Error, + danger: Accents.Danger +}; + +const ICONS: Record = { + success: ":white_check_mark:", + info: ":information_source:", + warn: ":warning:", + error: ":no_entry:", + danger: ":no_entry:" +}; + + +export type Notice = ComponentMessage; + +export function notice(kind: NoticeKind, content: string, options: NoticeOptions = {}): Notice { + const body: ComponentInContainerData[] = [text(`${ICONS[kind]} ${content}`)]; + if (options.components?.length) body.push(...options.components); + + return { + flags: MessageFlags.IsComponentsV2 | (options.ephemeral ? MessageFlags.Ephemeral : 0) | (options.flags ?? 0), + components: [container(body, {accentColor: ACCENTS[kind]})] + }; +} + +export const success = (content: string, options?: NoticeOptions): Notice => notice("success", content, options); +export const info = (content: string, options?: NoticeOptions): Notice => notice("info", content, options); +export const warn = (content: string, options?: NoticeOptions): Notice => notice("warn", content, options); +export const error = (content: string, options?: NoticeOptions): Notice => notice("error", content, options); +export const danger = (content: string, options?: NoticeOptions): Notice => notice("danger", content, options); diff --git a/src/util/stats.ts b/src/util/stats.ts new file mode 100644 index 0000000..931bec3 --- /dev/null +++ b/src/util/stats.ts @@ -0,0 +1,16 @@ +import type {ChatInputCommandInteraction} from "discord.js"; +import type {CommandStats} from "../types"; +import {statsDB} from "../db"; + + +/** Counts a command run against its guild, or against the bot for DMs. */ +export async function recordCommandRun(interaction: ChatInputCommandInteraction): Promise { + const key = interaction.guildId ?? interaction.client.user.id; + const name = interaction.commandName; + + const data: CommandStats = await statsDB.get(key) ?? {commands: {}}; + data.commands ??= {}; + data.commands[name] = (data.commands[name] ?? 0) + 1; + + await statsDB.set(key, data); +} diff --git a/tests/addons.test.ts b/tests/addons.test.ts new file mode 100644 index 0000000..3b4e9b0 --- /dev/null +++ b/tests/addons.test.ts @@ -0,0 +1,188 @@ +import {describe, expect, test} from "bun:test"; + +import {createAddonComponent, createAddonList, createNavigation, paginateAddonPages, sortAddons} from "../src/util/addons"; +import {isSessionId} from "../src/framework"; +import type {BdWebAddon} from "../src/types"; +import {sessionHarness} from "./helpers/session"; + + +function addon(over: Partial = {}): BdWebAddon { + return { + id: 7, + name: "CoolPlugin", + file_name: "c.plugin.js", + type: "plugin", + description: "Does things", + version: "1.2.3", + likes: 1234, + downloads: 56789, + tags: [], + thumbnail_url: "/resources/x.png", + latest_source_url: "u", + initial_release_date: new Date("2020-01-02T00:00:00Z"), + latest_release_date: new Date("2024-03-04T00:00:00Z"), + author: { + github_id: "1", + github_name: "g", + display_name: "d", + discord_name: "dn", + discord_avatar_hash: null, + discord_snowflake: "1", + guild: null + }, + guild: null, + ...over + }; +} + +const IS_COMPONENTS_V2 = 1 << 15; + +interface Row {type: number; components: Array<{options?: Array<{label: string; value: string; default: boolean}>; disabled?: boolean}>} +const navOf = (shown: Record) => (shown.components as unknown[])[0] as Row; +const menu = (shown: Record) => navOf(shown).components[0]; + + +describe("navigation menu", () => { + const list = [addon({name: "Alpha"}), addon({name: "Beta"}), addon({name: "Gamma"})]; + + test("is session-owned so the dispatcher leaves it alone", () => { + const control = createNavigation(list).components[0] as {customId: string}; + expect(isSessionId(control.customId)).toBe(true); + }); + + test("numbers the options and marks the selected one", () => { + const options = menu({components: [createNavigation(list, 1)]}).options ?? []; + expect(options.map(option => option.label)).toEqual(["1. Alpha", "2. Beta", "3. Gamma"]); + expect(options.map(option => option.default)).toEqual([false, true, false]); + }); +}); + + +describe("addon browser", () => { + const list = [addon({name: "Alpha"}), addon({name: "Beta"}), addon({name: "Gamma"})]; + + function browse(addons: BdWebAddon[]) { + const harness = sessionHarness(); + const done = paginateAddonPages(harness.interaction, addons); + return {harness, done, latest: () => harness.shown.at(-1) ?? {}}; + } + + test("an empty list answers instead of building an illegal select menu", async () => { + // Discord rejects a string select with zero options. + const {harness, done} = browse([]); + await done; + const shown = harness.shown.at(-1) ?? {}; + expect(Number(shown.flags) & IS_COMPONENTS_V2).toBe(IS_COMPONENTS_V2); + expect(JSON.stringify(shown)).toContain("No addons matched"); + }); + + test("renders the first addon with its menu", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["Alpha"]}); + const options = menu(harness.shown[0]).options ?? []; + expect(options).toHaveLength(3); + expect(options[0]?.default).toBe(true); + await harness.end(); + await done; + }); + + test("the menu is disabled once the session ends", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["Alpha"]}); + await harness.end(); + await done; + expect(menu(harness.shown.at(-1) ?? {}).disabled).toBe(true); + }); + + test("every render carries IsComponentsV2", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["Alpha"]}); + await harness.end(); + await done; + expect(harness.shown.every(shown => Number(shown.flags) === IS_COMPONENTS_V2)).toBe(true); + }); + + test("a stranger cannot drive the browser", async () => { + const {harness, done} = browse(list); + const refusals = await harness.press("addons-navigate", {userId: "someone-else", values: ["Beta"]}); + expect(String(refusals[0]?.content)).toContain("belongs to someone else"); + await harness.end(); + await done; + }); + + test("selecting an addon swaps the page and moves the tick", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["Gamma"]}); + + const options = menu(harness.shown.at(-1) ?? {}).options ?? []; + expect(options.map(option => option.default)).toEqual([false, false, true]); + expect(JSON.stringify(harness.shown.at(-1))).toContain("# Gamma v1.2.3"); + + await harness.end(); + await done; + }); + + // The previous version did `addons.find(...)!` and would have thrown. + test("an unknown value leaves the selection alone", async () => { + const {harness, done} = browse(list); + await harness.press("addons-navigate", {values: ["NoSuchAddon"]}); + const options = menu(harness.shown.at(-1) ?? {}).options ?? []; + expect(options.map(option => option.default)).toEqual([true, false, false]); + await harness.end(); + await done; + }); +}); + + +describe("list rendering", () => { + test("separates entries but does not trail one", () => { + const [, page] = createAddonList("Plugins", [addon({name: "A"}), addon({name: "B"})]); + const types = (page.components as Array<{type: number}>).map(component => component.type); + expect(types).toEqual([9, 14, 9]); + }); + + test("a single entry gets no separator", () => { + const [, page] = createAddonList("Plugins", [addon()]); + expect(page.components).toHaveLength(1); + }); + + test("the heading is a separate top-level text display", () => { + const [heading] = createAddonList("Plugins sorted by downloads", [addon()]); + expect(heading.content).toBe("## Plugins sorted by downloads"); + }); +}); + + +describe("sorting", () => { + test("orders by the numeric field, descending", () => { + const list = [addon({name: "a", likes: 1}), addon({name: "b", likes: 9}), addon({name: "c", likes: 5})]; + expect(sortAddons(list, "likes").map(a => a.name)).toEqual(["b", "c", "a"]); + }); + + test("orders by date, newest first", () => { + const list = [ + addon({name: "old", latest_release_date: new Date("2020-01-01T00:00:00Z")}), + addon({name: "new", latest_release_date: new Date("2024-01-01T00:00:00Z")}) + ]; + expect(sortAddons(list, "latest_release_date").map(a => a.name)).toEqual(["new", "old"]); + }); +}); + + +describe("addon page", () => { + test("adds a support-server button only when the author has a guild", () => { + const withoutGuild = createAddonComponent(addon()); + const withGuild = createAddonComponent(addon({ + author: {...addon().author, guild: {name: "G", snowflake: "1", invite_link: "https://discord.gg/abc"}} + })); + const labels = (page: {components: readonly unknown[]}) => + JSON.stringify(page.components).match(/"label":"[^"]+"/g) ?? []; + expect(labels(withoutGuild)).toHaveLength(2); + expect(labels(withGuild)).toHaveLength(3); + }); + + test("falls back when an addon has no description", () => { + const page = createAddonComponent(addon({description: undefined as unknown as string})); + expect(JSON.stringify(page)).toContain("No description provided."); + }); +}); diff --git a/tests/commands.test.ts b/tests/commands.test.ts new file mode 100644 index 0000000..f66a355 --- /dev/null +++ b/tests/commands.test.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import {describe, expect, test} from "bun:test"; +import {loadCommands} from "../src/framework"; +import expected from "./fixtures/command-payloads.json"; + + +/** + * A snapshot of exactly what gets deployed to Discord. + * + * Refactors are supposed to leave this untouched; when one legitimately + * changes a command, the diff here is the review. Regenerate with: + * + * bun run tests/fixtures/regenerate-payloads.ts + */ + +const sortKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === "object") { + const record = value as Record; + return Object.fromEntries(Object.keys(record).sort().map(key => [key, sortKeys(record[key])])); + } + return value; +}; + +async function currentPayloads(): Promise> { + const payloads: Record = {}; + for (const command of await loadCommands(path.join(import.meta.dir, "..", "src", "commands"))) { + const data: unknown = JSON.parse(JSON.stringify(command.data)); + payloads[command.name] = sortKeys({data, ownerOnly: command.ownerOnly}); + } + return payloads; +} + + +describe("deployed command payloads", () => { + test("match the committed snapshot", async () => { + expect(await currentPayloads()).toEqual(expected as Record); + }); + + test("the snapshot covers every command that loads", async () => { + expect(Object.keys(await currentPayloads()).sort()).toEqual(Object.keys(expected).sort()); + }); +}); + + +describe("payload invariants", () => { + test("owner-only commands are deployed to the guild, not globally", async () => { + const commands = await loadCommands(path.join(import.meta.dir, "..", "src", "commands")); + expect(commands.filter(command => command.ownerOnly).map(command => command.name)).toEqual(["botadmin"]); + }); + + test("no command still uses the deprecated dm_permission field", async () => { + for (const command of await loadCommands(path.join(import.meta.dir, "..", "src", "commands"))) { + expect(command.data).not.toHaveProperty("dm_permission"); + } + }); + + test("subcommand options come last, as the API requires", async () => { + for (const command of await loadCommands(path.join(import.meta.dir, "..", "src", "commands"))) { + for (const option of command.data.options ?? []) { + const nested = (option as {options?: Array<{required?: boolean}>}).options ?? []; + const firstOptional = nested.findIndex(child => child.required !== true); + if (firstOptional === -1) continue; + expect(nested.slice(firstOptional).every(child => child.required !== true)).toBe(true); + } + } + }); +}); diff --git a/tests/dispatch.test.ts b/tests/dispatch.test.ts new file mode 100644 index 0000000..ca14480 --- /dev/null +++ b/tests/dispatch.test.ts @@ -0,0 +1,196 @@ +import {beforeEach, describe, expect, test} from "bun:test"; +import {Dispatcher} from "../src/framework/dispatch"; +import {defineCommand, defineComponent} from "../src/framework/registry"; +import {Num, oneOf} from "../src/framework/ids"; +import {sessionId} from "../src/framework/session"; +import {lastReply, silenceConsole, stubInteraction} from "./helpers/interactions"; + + +const calls: string[] = []; + +const picker = defineComponent({ + id: "demo.pick", + kind: "button", + guildOnly: true, + params: {mode: oneOf("user", "admin"), page: Num}, + run: (_interaction, {mode, page}) => {calls.push(`pick:${mode}:${page}`); return Promise.resolve();} +}); + +const anywhere = defineComponent({ + id: "demo.any", + kind: "button", + params: {}, + run: () => {calls.push("any"); return Promise.resolve();} +}); + +const guildCommand = defineCommand({ + guildOnly: true, + data: {name: "demo", description: "d"}, + execute: () => {calls.push("demo"); return Promise.resolve();}, + autocomplete: () => {calls.push("demo:auto"); return Promise.resolve();} +}); + +const ownerCommand = defineCommand({ + ownerOnly: true, + data: {name: "secret", description: "d"}, + execute: () => {calls.push("secret"); return Promise.resolve();} +}); + + +function build() { + const dispatcher = new Dispatcher({ownerId: "owner-1"}); + dispatcher.addCommand(guildCommand); + dispatcher.addCommand(ownerCommand); + dispatcher.addComponent(picker); + dispatcher.addComponent(anywhere); + return dispatcher; +} + +beforeEach(() => {calls.length = 0;}); + + +describe("command routing", () => { + test("runs a registered command", async () => { + await build().dispatch(stubInteraction({kind: "chat", commandName: "demo"}).interaction); + expect(calls).toEqual(["demo"]); + }); + + test("guildOnly is enforced before the handler runs", async () => { + const stub = stubInteraction({kind: "chat", commandName: "demo", cached: false}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + expect(lastReply(stub)).toContain("can't use that command here"); + }); + + test("ownerOnly is enforced before the handler runs", async () => { + const stub = stubInteraction({kind: "chat", commandName: "secret", userId: "someone-else"}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + + const owner = stubInteraction({kind: "chat", commandName: "secret", userId: "owner-1"}); + await build().dispatch(owner.interaction); + expect(calls).toEqual(["secret"]); + }); + + test("an unregistered command says so rather than failing silently", async () => { + const restore = silenceConsole(); + const stub = stubInteraction({kind: "chat", commandName: "ghost"}); + await build().dispatch(stub.interaction); + restore(); + expect(lastReply(stub)).toContain("isn't registered"); + }); + + test("onCommandRun fires for stats, once per command", async () => { + const seen: string[] = []; + const dispatcher = new Dispatcher({ownerId: "owner-1", onCommandRun: i => {seen.push(i.commandName); return Promise.resolve();}}); + dispatcher.addCommand(guildCommand); + await dispatcher.dispatch(stubInteraction({kind: "chat", commandName: "demo"}).interaction); + expect(seen).toEqual(["demo"]); + }); +}); + + +describe("autocomplete routing", () => { + test("reaches the command's autocomplete handler", async () => { + await build().dispatch(stubInteraction({kind: "autocomplete", commandName: "demo"}).interaction); + expect(calls).toEqual(["demo:auto"]); + }); + + test("responds empty rather than throwing when there is no handler", async () => { + const stub = stubInteraction({kind: "autocomplete", commandName: "secret"}); + await build().dispatch(stub.interaction); + expect(stub.autocompleteResponses).toEqual([[]]); + }); +}); + + +describe("component routing", () => { + test("decodes params and passes them typed", async () => { + await build().dispatch(stubInteraction({kind: "button", customId: picker.customId({mode: "admin", page: 7})}).interaction); + expect(calls).toEqual(["pick:admin:7"]); + }); + + test("guildOnly components are gated too", async () => { + const stub = stubInteraction({kind: "button", customId: picker.customId({mode: "user", page: 1}), cached: false}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + expect(lastReply(stub)).toContain("can't use that"); + }); + + test("a component registered for one kind refuses another", async () => { + const restore = silenceConsole(); + await build().dispatch(stubInteraction({kind: "stringSelect", customId: anywhere.customId({})}).interaction); + restore(); + expect(calls).toEqual([]); + }); + + test("a stale id from before a deploy explains itself", async () => { + const restore = silenceConsole(); + const stub = stubInteraction({kind: "button", customId: "demo.pick:admin"}); + await build().dispatch(stub.interaction); + restore(); + expect(calls).toEqual([]); + expect(lastReply(stub)).toContain("out of date"); + }); + + test("a malformed param value explains itself the same way", async () => { + const restore = silenceConsole(); + const stub = stubInteraction({kind: "button", customId: "demo.pick:sudo:1"}); + await build().dispatch(stub.interaction); + restore(); + expect(lastReply(stub)).toContain("out of date"); + }); + + // This silence is the contract that lets sessions and registered + // components share one custom-id space. + test("session-owned ids are left to their own collector", async () => { + const stub = stubInteraction({kind: "button", customId: sessionId("next")}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + expect(stub.replies).toEqual([]); + }); + + test("an unknown namespace is ignored, not treated as an error", async () => { + const stub = stubInteraction({kind: "button", customId: "nobody.knows:1"}); + await build().dispatch(stub.interaction); + expect(stub.replies).toEqual([]); + }); + + // The pre-framework router matched on customId.split("-")[0]. + test("hyphenated ids no longer route anywhere", async () => { + const stub = stubInteraction({kind: "roleSelect", customId: "cleanname-whatever"}); + await build().dispatch(stub.interaction); + expect(calls).toEqual([]); + expect(stub.replies).toEqual([]); + }); +}); + + +describe("registration and failure", () => { + test("duplicate command names throw at startup", () => { + const dispatcher = build(); + expect(() => dispatcher.addCommand(guildCommand)).toThrow(/duplicate command/); + }); + + test("duplicate component namespaces throw at startup", () => { + const dispatcher = build(); + expect(() => dispatcher.addComponent(picker)).toThrow(/duplicate component/); + }); + + test("a throwing handler is reported to the user, not swallowed", async () => { + const restore = silenceConsole(); + const dispatcher = new Dispatcher({ownerId: "owner-1"}); + dispatcher.addCommand(defineCommand({ + data: {name: "boom", description: "d"}, + execute: () => {throw new Error("kaboom");} + })); + const stub = stubInteraction({kind: "chat", commandName: "boom"}); + await dispatcher.dispatch(stub.interaction); + restore(); + expect(lastReply(stub)).toContain("Something went wrong"); + }); + + test("counts report what is registered", () => { + expect(build().counts).toEqual({commands: 2, components: 2}); + }); +}); diff --git a/tests/fixtures/broken/nocommand.ts b/tests/fixtures/broken/nocommand.ts new file mode 100644 index 0000000..bb1dad6 --- /dev/null +++ b/tests/fixtures/broken/nocommand.ts @@ -0,0 +1,2 @@ +/** A module that exports nothing the loader can use. */ +export const somethingElse = 42; diff --git a/tests/fixtures/command-payloads.json b/tests/fixtures/command-payloads.json new file mode 100644 index 0000000..67b0bc2 --- /dev/null +++ b/tests/fixtures/command-payloads.json @@ -0,0 +1,580 @@ +{ + "about": { + "data": { + "contexts": [ + 0, + 1, + 2 + ], + "description": "Gives some information about the bot", + "integration_types": [ + 0, + 1 + ], + "name": "about", + "type": 1 + }, + "ownerOnly": false + }, + "addons": { + "data": { + "contexts": [ + 0, + 1, + 2 + ], + "description": "Commands for addons.", + "integration_types": [ + 0, + 1 + ], + "name": "addons", + "options": [ + { + "description": "Shows the most recently updated addons", + "name": "updated", + "type": 1 + }, + { + "description": "Shows the newest added addons", + "name": "newest", + "type": 1 + }, + { + "description": "Shows the most liked addons", + "name": "top", + "type": 1 + }, + { + "description": "Shows the most downloaded addons", + "name": "popular", + "type": 1 + }, + { + "description": "Shows a random addon", + "name": "random", + "type": 1 + }, + { + "description": "Searches for an addon by name", + "name": "search", + "options": [ + { + "autocomplete": true, + "description": "Name of the addon to find", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Gets information about an addon", + "name": "info", + "options": [ + { + "autocomplete": true, + "description": "Name of the addon to get info about", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Browse addons in an interactive way", + "name": "browse", + "options": [ + { + "autocomplete": true, + "description": "tag to browse", + "name": "tag", + "required": false, + "type": 3 + }, + { + "choices": [ + { + "name": "Plugin", + "value": "plugin" + }, + { + "name": "Theme", + "value": "theme" + } + ], + "description": "type to browse", + "name": "type", + "required": false, + "type": 3 + }, + { + "choices": [ + { + "name": "Newest", + "value": "initial_release_date" + }, + { + "name": "Last Updated", + "value": "latest_release_date" + }, + { + "name": "Most Liked", + "value": "likes" + }, + { + "name": "Popular", + "value": "downloads" + } + ], + "description": "sort method", + "name": "sort", + "required": false, + "type": 3 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "botadmin": { + "data": { + "description": "Global settings for the bot during runtime.", + "name": "botadmin", + "options": [ + { + "description": "Sends messages to different locations", + "name": "send", + "options": [ + { + "description": "Sends a DM to the specified user.", + "name": "user", + "options": [ + { + "description": "User to DM.", + "name": "user", + "required": true, + "type": 6 + } + ], + "type": 1 + }, + { + "description": "Sends a message to the specified channel.", + "name": "channel", + "options": [ + { + "channel_types": [ + 0 + ], + "description": "Channel to send a message.", + "name": "channel", + "required": true, + "type": 7 + } + ], + "type": 1 + } + ], + "type": 2 + }, + { + "description": "Sets up DM forwarding to a user.", + "name": "forwarding", + "options": [ + { + "description": "Who to forward DMs to?", + "name": "user", + "required": false, + "type": 6 + } + ], + "type": 1 + }, + { + "description": "Exits the bot gracefully.", + "name": "quit", + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": true + }, + "cleanname": { + "data": { + "contexts": [ + 0 + ], + "default_member_permissions": "32", + "description": "Cleans member display names to match Discord's username standards.", + "name": "cleanname", + "options": [ + { + "description": "Toggles automatically cleaning new members when they join.", + "name": "join", + "options": [ + { + "description": "Whether members should have their display name cleaned upon joining.", + "name": "enabled", + "required": true, + "type": 5 + } + ], + "type": 1 + }, + { + "description": "Fixes a display name for a single user.", + "name": "user", + "options": [ + { + "description": "Whose display name should be cleaned?", + "name": "user", + "required": true, + "type": 6 + } + ], + "type": 1 + }, + { + "description": "Fixes all display names in the server.", + "name": "server", + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "developer": { + "data": { + "contexts": [ + 0 + ], + "description": "Manage roles for developers in the community.", + "name": "developer", + "options": [ + { + "description": "Adds a new developer or new role to an existing developer.", + "name": "add", + "options": [ + { + "description": "Who is the developer in question?", + "name": "user", + "required": true, + "type": 6 + }, + { + "choices": [ + { + "name": "Plugin Developer", + "value": "Plugin Developer" + }, + { + "name": "Theme Developer", + "value": "Theme Developer" + } + ], + "description": "Role to add.", + "name": "role", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Syncs roles between servers.", + "name": "sync", + "options": [ + { + "description": "Which developer to resync?", + "name": "user", + "required": true, + "type": 6 + } + ], + "type": 1 + }, + { + "description": "Sets a channel to send invite messages.", + "name": "channel", + "options": [ + { + "description": "Which channel ID to send invites?", + "name": "channel", + "required": false, + "type": 3 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "moderation": { + "data": { + "contexts": [ + 0 + ], + "default_member_permissions": "32", + "description": "Commands for moderating the server.", + "name": "moderation", + "options": [ + { + "description": "Toggles the invite filter module.", + "name": "invitefilter", + "options": [ + { + "description": "Enable or disable", + "name": "enable", + "required": false, + "type": 5 + } + ], + "type": 1 + }, + { + "description": "Toggles the spam detection module.", + "name": "detectspam", + "options": [ + { + "description": "Enable or disable", + "name": "enable", + "required": false, + "type": 5 + } + ], + "type": 1 + }, + { + "description": "Sets a channel to log bot moderation actions.", + "name": "modlog", + "options": [ + { + "channel_types": [ + 0 + ], + "description": "Where to log my actions?", + "name": "channel", + "required": false, + "type": 7 + } + ], + "type": 1 + }, + { + "description": "Sets a channel to log join/leave messages.", + "name": "joinleave", + "options": [ + { + "channel_types": [ + 0 + ], + "description": "Where to log join/leave messages?", + "name": "channel", + "required": false, + "type": 7 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "selfroles": { + "data": { + "contexts": [ + 0 + ], + "description": "Allows users to self-assign roles.", + "name": "selfroles", + "type": 1 + }, + "ownerOnly": false + }, + "spam": { + "data": { + "contexts": [ + 0 + ], + "default_member_permissions": "8192", + "description": "Commands for dealing with spam.", + "name": "spam", + "options": [ + { + "description": "Adds a link to the automod spam link filter", + "name": "link", + "options": [ + { + "description": "Link to add to the filter", + "name": "link", + "required": true, + "type": 3 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "tag": { + "data": { + "contexts": [ + 0 + ], + "description": "Saving and recalling custom tags.", + "integration_types": [ + 0 + ], + "name": "tag", + "options": [ + { + "description": "List all tags in this server", + "name": "list", + "type": 1 + }, + { + "description": "View a tag", + "name": "view", + "options": [ + { + "autocomplete": true, + "description": "Name of the tag to view", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Update a tag", + "name": "update", + "options": [ + { + "autocomplete": true, + "description": "Name of the tag to update", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Delete a tag", + "name": "delete", + "options": [ + { + "autocomplete": true, + "description": "Name of the tag to delete", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + }, + { + "description": "Create a new tag", + "name": "create", + "options": [ + { + "autocomplete": false, + "description": "Name of the tag to create", + "name": "name", + "required": true, + "type": 3 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + }, + "voicetext": { + "data": { + "contexts": [ + 0 + ], + "default_member_permissions": "32", + "description": "Binds one voice and one text channel together.", + "name": "voicetext", + "options": [ + { + "description": "Checks the bound status of a voice channel.", + "name": "status", + "options": [ + { + "channel_types": [ + 2 + ], + "description": "Which voice channel to check?", + "name": "channel", + "required": true, + "type": 7 + } + ], + "type": 1 + }, + { + "description": "Unbinds a voice channel from its partner.", + "name": "unbind", + "options": [ + { + "channel_types": [ + 2 + ], + "description": "Which voice channel to unbind?", + "name": "channel", + "required": true, + "type": 7 + } + ], + "type": 1 + }, + { + "description": "Binds a voice and text channel together.", + "name": "bind", + "options": [ + { + "channel_types": [ + 2 + ], + "description": "Which voice channel to bind?", + "name": "voice", + "required": true, + "type": 7 + }, + { + "channel_types": [ + 0 + ], + "description": "Which text channel to bind with?", + "name": "text", + "required": true, + "type": 7 + } + ], + "type": 1 + } + ], + "type": 1 + }, + "ownerOnly": false + } +} diff --git a/tests/fixtures/events/multiple.ts b/tests/fixtures/events/multiple.ts new file mode 100644 index 0000000..9364400 --- /dev/null +++ b/tests/fixtures/events/multiple.ts @@ -0,0 +1,12 @@ +import {Events} from "discord.js"; +import {defineEvents} from "../../../src/framework"; + +/** + * The shape src/events/joinleave.ts uses. The pre-framework loader read `.name` + * off the array, registered `client.on(undefined, ...)`, and the listeners + * never fired. + */ +export default defineEvents( + {name: Events.GuildMemberAdd, execute: () => Promise.resolve()}, + {name: Events.GuildMemberRemove, execute: () => Promise.resolve()} +); diff --git a/tests/fixtures/events/single.ts b/tests/fixtures/events/single.ts new file mode 100644 index 0000000..8a312f4 --- /dev/null +++ b/tests/fixtures/events/single.ts @@ -0,0 +1,7 @@ +import {Events} from "discord.js"; +import {defineEvent} from "../../../src/framework"; + +export default defineEvent({ + name: Events.MessageCreate, + execute: () => Promise.resolve() +}); diff --git a/tests/fixtures/regenerate-payloads.ts b/tests/fixtures/regenerate-payloads.ts new file mode 100644 index 0000000..b1ab29e --- /dev/null +++ b/tests/fixtures/regenerate-payloads.ts @@ -0,0 +1,26 @@ +/** + * Rewrites tests/fixtures/command-payloads.json from the current source. + * Run this when a command change is intended, and review the resulting diff. + */ + +import path from "node:path"; +import {loadCommands} from "../../src/framework"; + +const sortKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === "object") { + const record = value as Record; + return Object.fromEntries(Object.keys(record).sort().map(key => [key, sortKeys(record[key])])); + } + return value; +}; + +const payloads: Record = {}; +for (const command of await loadCommands(path.join(import.meta.dir, "..", "..", "src", "commands"))) { + const data: unknown = JSON.parse(JSON.stringify(command.data)); + payloads[command.name] = sortKeys({data, ownerOnly: command.ownerOnly}); +} + +const target = path.join(import.meta.dir, "command-payloads.json"); +await Bun.write(target, JSON.stringify(payloads, null, 2) + "\n"); +console.log(`Wrote ${Object.keys(payloads).length} command payloads to ${path.relative(process.cwd(), target)}`); diff --git a/tests/helpers/interactions.ts b/tests/helpers/interactions.ts new file mode 100644 index 0000000..a2a0baf --- /dev/null +++ b/tests/helpers/interactions.ts @@ -0,0 +1,91 @@ +/** + * Minimal stand-ins for discord.js interactions. + * + * The dispatcher only ever calls the type guards and a handful of reply + * methods, so a plain object is enough and keeps the tests free of network or + * gateway setup. The single cast is confined to this file. + */ + +import type {Interaction} from "discord.js"; + + +export type StubKind = "chat" | "autocomplete" | "button" | "stringSelect" | "roleSelect" | "modal"; + +export interface StubOptions { + kind: StubKind; + customId?: string; + commandName?: string; + userId?: string; + /** false simulates a DM or an uncached guild. */ + cached?: boolean; + values?: string[]; + deferred?: boolean; + replied?: boolean; +} + +export interface Stub { + interaction: Interaction; + /** Everything the code under test sent back, in order. */ + replies: Array>; + updates: Array>; + autocompleteResponses: unknown[][]; +} + + +export function stubInteraction(options: StubOptions): Stub { + const {kind, customId = "", commandName = "", userId = "user-1", cached = true, values = []} = options; + + const replies: Array> = []; + const updates: Array> = []; + const autocompleteResponses: unknown[][] = []; + + const interaction = { + customId, + commandName, + values, + user: {id: userId}, + deferred: options.deferred ?? false, + replied: options.replied ?? false, + + isChatInputCommand: () => kind === "chat", + isAutocomplete: () => kind === "autocomplete", + isMessageComponent: () => kind === "button" || kind === "stringSelect" || kind === "roleSelect", + isModalSubmit: () => kind === "modal", + isButton: () => kind === "button", + isStringSelectMenu: () => kind === "stringSelect", + isRoleSelectMenu: () => kind === "roleSelect", + isUserSelectMenu: () => false, + isChannelSelectMenu: () => false, + isMentionableSelectMenu: () => false, + isRepliable: () => true, + inCachedGuild: () => cached, + + reply: (payload: Record) => {replies.push(payload); return Promise.resolve();}, + followUp: (payload: Record) => {replies.push(payload); return Promise.resolve();}, + update: (payload: Record) => {updates.push(payload); return Promise.resolve();}, + deferUpdate: () => Promise.resolve(), + respond: (choices: unknown[]) => {autocompleteResponses.push(choices); return Promise.resolve();} + }; + + return {interaction: interaction as unknown as Interaction, replies, updates, autocompleteResponses}; +} + +/** Text of the last thing sent back, for terse assertions. */ +export function lastReply(stub: Stub): string { + const content = stub.replies.at(-1)?.content; + return typeof content === "string" ? content : ""; +} + + +/** + * Silences console output for one test. Several dispatcher paths log on + * purpose (a stale id, an unregistered command, a handler that threw); this + * keeps the suite output clean so a real failure stands out, and marks those + * tests as expecting the noise. + */ +export function silenceConsole(): () => void { + const {error, warn} = console; + console.error = () => {}; + console.warn = () => {}; + return () => {console.error = error; console.warn = warn;}; +} diff --git a/tests/helpers/session.ts b/tests/helpers/session.ts new file mode 100644 index 0000000..d34a3d2 --- /dev/null +++ b/tests/helpers/session.ts @@ -0,0 +1,93 @@ +/** + * A stand-in for the message + component collector that runSession drives. + * `press()` delivers a click the way discord.js would. + */ + +import {EventEmitter} from "node:events"; +import type {RepliableInteraction} from "discord.js"; + + +export interface PressOptions { + userId?: string; + /** Present for a select menu; its absence makes the stub a button. */ + values?: string[]; +} + +export interface SessionHarness { + interaction: RepliableInteraction; + /** Every payload a viewer would have seen, from editReply or update. */ + shown: Array>; + /** Returns anything the component replied with, e.g. an ownership refusal. */ + press(action: string, options?: PressOptions): Promise>>; + end(): Promise; +} + + +export function sessionHarness(ownerId = "owner"): SessionHarness { + const collector = Object.assign(new EventEmitter(), { + // runSession calls stop() on its error path. + stop: () => {collector.emit("end");} + }); + const shown: Array> = []; + + const interaction = { + deferred: true, + replied: false, + user: {id: ownerId}, + deferReply: () => Promise.resolve(), + editReply: (payload: Record) => { + shown.push(payload); + return Promise.resolve({createMessageComponentCollector: () => collector}); + } + }; + + const settle = () => new Promise(resolve => setImmediate(resolve)); + + /** + * runSession attaches its collector after two awaits, so a press issued + * immediately after starting the session would otherwise be emitted into + * the void. + */ + async function whenListening() { + for (let attempt = 0; attempt < 100 && collector.listenerCount("collect") === 0; attempt++) await settle(); + if (collector.listenerCount("collect") === 0) throw new Error("session never attached a collector"); + } + + return { + interaction: interaction as unknown as RepliableInteraction, + shown, + + async press(action, {userId = ownerId, values}: PressOptions = {}) { + await whenListening(); + const refusals: Array> = []; + const isSelect = values !== undefined; + const component = { + customId: `~${action}`, + user: {id: userId}, + values: values ?? [], + replied: false, + deferred: false, + + isButton: () => !isSelect, + isStringSelectMenu: () => isSelect, + isRoleSelectMenu: () => false, + isUserSelectMenu: () => false, + isChannelSelectMenu: () => false, + isMentionableSelectMenu: () => false, + + reply: (payload: Record) => {refusals.push(payload); return Promise.resolve();}, + update: (payload: Record) => {shown.push(payload); return Promise.resolve();}, + deferUpdate: () => Promise.resolve() + }; + collector.emit("collect", component); + await settle(); + return refusals; + }, + + async end() { + await whenListening(); + collector.emit("end"); + await settle(); + } + }; +} diff --git a/tests/ids.test.ts b/tests/ids.test.ts new file mode 100644 index 0000000..5aa46f5 --- /dev/null +++ b/tests/ids.test.ts @@ -0,0 +1,68 @@ +import {describe, expect, test} from "bun:test"; +import {Bool, Id, IdError, MAX_CUSTOM_ID, Num, Str, decodeId, encodeId, namespaceOf, oneOf} from "../src/framework/ids"; + + +describe("custom id codec", () => { + const spec = {a: Str, b: Str, c: Num, d: Bool}; + + test.each([ + ["separator in a value", {a: "a:b", b: "plain", c: 1, d: true}], + ["percent and separator", {a: "100%:sure", b: "%3A", c: -2.5, d: false}], + ["empty and repeated separators", {a: "", b: "::::", c: 0, d: true}], + ["non-ascii", {a: "emoji 🎭 ok", b: "a%b:c", c: 42, d: false}] + ])("round-trips %s", (_label, params) => { + expect(decodeId(spec, encodeId("ns", spec, params))).toEqual(params); + }); + + test("namespace is the prefix and survives escaping", () => { + expect(namespaceOf(encodeId("some.thing", spec, {a: "x:y", b: "", c: 1, d: false}))).toBe("some.thing"); + }); + + test("a spec with no params encodes to just the namespace", () => { + expect(encodeId("bare", {}, {})).toBe("bare"); + expect(decodeId({}, "bare")).toEqual({}); + }); +}); + + +describe("codec validation", () => { + test("rejects an id over Discord's 100-character limit", () => { + expect(() => encodeId("ns", {a: Str}, {a: "y".repeat(MAX_CUSTOM_ID)})).toThrow(IdError); + }); + + test("accepts an id exactly at the limit", () => { + const id = encodeId("ns", {a: Str}, {a: "y".repeat(MAX_CUSTOM_ID - "ns:".length)}); + expect(id).toHaveLength(MAX_CUSTOM_ID); + }); + + test("rejects a malformed snowflake in both directions", () => { + expect(() => Id.format("nope")).toThrow(IdError); + expect(() => Id.parse("12")).toThrow(IdError); + expect(Id.parse("123456789012345678")).toBe("123456789012345678"); + }); + + test("rejects a non-numeric value for a number param", () => { + expect(() => decodeId({n: Num}, "ns:banana")).toThrow(IdError); + }); + + test("rejects the wrong number of params, which is what a stale id looks like", () => { + const spec = {a: Str, b: Str}; + expect(() => decodeId(spec, "ns:only-one")).toThrow(IdError); + expect(() => decodeId(spec, "ns:a:b:c")).toThrow(IdError); + }); + + // Every codec must fail loudly on a malformed value; Bool used to decode + // anything that was not "1" as false, so a tampered or stale id could slip + // through instead of taking the "out of date" path. + test("Bool rejects anything that is not 0 or 1", () => { + expect(Bool.parse("1")).toBe(true); + expect(Bool.parse("0")).toBe(false); + for (const bad of ["", "true", "banana", "2"]) expect(() => Bool.parse(bad)).toThrow(IdError); + }); + + test("oneOf rejects a value outside the set", () => { + const mode = oneOf("user", "admin"); + expect(mode.parse("admin")).toBe("admin"); + expect(() => mode.parse("root")).toThrow(IdError); + }); +}); diff --git a/tests/loader.test.ts b/tests/loader.test.ts new file mode 100644 index 0000000..4ce327b --- /dev/null +++ b/tests/loader.test.ts @@ -0,0 +1,61 @@ +import path from "node:path"; +import {describe, expect, test} from "bun:test"; +import {Dispatcher, loadCommands, loadEvents} from "../src/framework"; + + +const root = path.join(import.meta.dir, ".."); +const fixtures = path.join(import.meta.dir, "fixtures"); + + +describe("loading the real bot", () => { + test("every command file exports a usable command", async () => { + const commands = await loadCommands(path.join(root, "src", "commands")); + expect(commands.length).toBeGreaterThan(0); + for (const command of commands) { + expect(typeof command.name).toBe("string"); + expect(command.data.name).toBe(command.name); + } + }); + + test("command names are unique and API-legal", async () => { + const commands = await loadCommands(path.join(root, "src", "commands")); + const names = commands.map(command => command.name); + expect(new Set(names).size).toBe(names.length); + for (const name of names) expect(name).toMatch(/^[-_'\p{L}\p{N}]{1,32}$/u); + }); + + test("descriptions stay inside Discord's limits", async () => { + for (const command of await loadCommands(path.join(root, "src", "commands"))) { + expect(command.data.description.length).toBeGreaterThan(0); + expect(command.data.description.length).toBeLessThanOrEqual(100); + } + }); + + test("everything registers without a duplicate name or namespace", async () => { + const dispatcher = new Dispatcher({ownerId: "owner"}); + const commands = await loadCommands(path.join(root, "src", "commands")); + for (const command of commands) command.register(dispatcher); + expect(dispatcher.counts.commands).toBe(commands.length); + }); + + test("every event file yields listeners with a name and an execute", async () => { + const events = await loadEvents(path.join(root, "src", "events")); + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(typeof event.name).toBe("string"); + expect(typeof event.execute).toBe("function"); + } + }); +}); + + +describe("loader contract", () => { + test("a file may export several listeners", async () => { + const events = await loadEvents(path.join(fixtures, "events")); + expect(events.map(event => event.name).sort()).toEqual(["guildMemberAdd", "guildMemberRemove", "messageCreate"]); + }); + + test("a command module with no exported command throws, naming the file", () => { + expect(loadCommands(path.join(fixtures, "broken"))).rejects.toThrow(/nocommand\.ts/); + }); +}); diff --git a/tests/messages.test.ts b/tests/messages.test.ts new file mode 100644 index 0000000..a6e88cf --- /dev/null +++ b/tests/messages.test.ts @@ -0,0 +1,116 @@ +import {describe, expect, test} from "bun:test"; +import {ButtonStyle, ComponentType, MessageFlags} from "discord.js"; +import {row} from "../src/framework"; +import * as notices from "../src/util/notices"; +import {modLogMessage} from "../src/util/modlog"; +import {Accents} from "../src/util/colors"; +import {tagContainer, updateTagModal} from "../src/components/tags"; + + +const CONTAINER = 17; +const SECTION = 9; +const TEXT_DISPLAY = 10; +const THUMBNAIL = 11; + +interface Container { + type: number; + accentColor?: number; + components: Array<{type: number; content?: string; components?: Array<{content: string}>; accessory?: unknown}>; +} +const containerOf = (message: {components: unknown[]}) => message.components[0] as Container; + + +describe("notices", () => { + test.each(["success", "info", "warn", "error", "danger"] as const)("%s renders one accented container", kind => { + const message = notices.notice(kind, "hello"); + const container = containerOf(message); + expect(container.type).toBe(CONTAINER); + expect(container.accentColor).toBe(Accents[`${kind[0].toUpperCase()}${kind.slice(1)}` as keyof typeof Accents]); + expect(container.components[0]?.content).toContain("hello"); + }); + + test("always sets IsComponentsV2", () => { + expect(notices.info("x").flags & MessageFlags.IsComponentsV2).toBe(MessageFlags.IsComponentsV2); + }); + + test("ephemeral adds the Ephemeral flag without dropping V2", () => { + const flags = notices.error("x", {ephemeral: true}).flags; + expect(flags & MessageFlags.Ephemeral).toBe(MessageFlags.Ephemeral); + expect(flags & MessageFlags.IsComponentsV2).toBe(MessageFlags.IsComponentsV2); + }); + + // V2 puts action rows inside the container, not alongside it. + test("action rows are nested inside the container", () => { + const actions = row({type: ComponentType.Button, customId: "a", label: "A", style: ButtonStyle.Primary}); + const container = containerOf(notices.info("pick", {components: [actions]})); + expect(container.components.map(c => c.type)).toEqual([TEXT_DISPLAY, 1]); + }); + + test("each kind carries its own icon", () => { + expect(String(containerOf(notices.success("x")).components[0]?.content)).toContain(":white_check_mark:"); + expect(String(containerOf(notices.error("x")).components[0]?.content)).toContain(":no_entry:"); + }); +}); + + +describe("moderation log entries", () => { + const entry = { + heading: "spammer", + body: "Message sent by spammer in #general", + reason: "Fake Discord Link", + userId: "123456789012345678", + at: 1_750_000_000_000 + }; + + test("with an avatar, the heading/body/reason sit in a thumbnailed section", () => { + const container = containerOf(modLogMessage({...entry, iconUrl: "https://cdn/avatar.png"})); + const section = container.components[0]; + expect(section?.type).toBe(SECTION); + expect(section?.accessory).toMatchObject({type: THUMBNAIL}); + expect(section?.components?.map(c => c.content)).toEqual([ + "### spammer", + "Message sent by spammer in #general", + "**Reason:** Fake Discord Link" + ]); + }); + + test("without an avatar, the lines are flat with no section", () => { + const container = containerOf(modLogMessage(entry)); + expect(container.components.every(c => c.type === TEXT_DISPLAY)).toBe(true); + }); + + test("the footer carries the user id and a Discord timestamp", () => { + const container = containerOf(modLogMessage(entry)); + expect(container.components.at(-1)?.content).toBe("-# ID: 123456789012345678 • "); + }); + + test("an empty body does not produce an empty text display", () => { + const container = containerOf(modLogMessage({...entry, body: ""})); + expect(container.components[1]?.content).not.toBe(""); + }); +}); + + +describe("tag rendering", () => { + test("title becomes a heading above the content", () => { + const container = tagContainer({name: "t", title: "Hello", content: "Body"}) as unknown as Container; + expect(container.components.map(c => c.content)).toEqual(["# Hello", "Body"]); + }); + + test("no title means no heading", () => { + const container = tagContainer({name: "t", content: "Body"}) as unknown as Container; + expect(container.components.map(c => c.content)).toEqual(["Body"]); + }); + + test("a thumbnail wraps the text in a section", () => { + const container = tagContainer({name: "t", content: "Body", thumbnailUrl: "https://x/y.png"}) as unknown as Container; + expect(container.components[0]?.type).toBe(SECTION); + expect(container.components[0]?.accessory).toMatchObject({type: THUMBNAIL}); + }); + + test("the modal has the three expected fields and titles itself by intent", () => { + expect(updateTagModal({name: "t"}).title).toBe("Create Tag: t"); + expect(updateTagModal({name: "t", content: "c"}).title).toBe("Update Tag: t"); + expect(updateTagModal({name: "t"}).components).toHaveLength(3); + }); +}); diff --git a/tests/paginator.test.ts b/tests/paginator.test.ts new file mode 100644 index 0000000..f29df2f --- /dev/null +++ b/tests/paginator.test.ts @@ -0,0 +1,96 @@ +import {describe, expect, test} from "bun:test"; +import {paginate} from "../src/paginator"; +import {sessionHarness} from "./helpers/session"; + + +const IS_COMPONENTS_V2 = 1 << 15; + +interface Rendered { + flags?: unknown; + components?: Array<{content?: string; components?: Array<{label: string; disabled?: boolean}>}>; +} + +function paginated(count: number, perPage = 10) { + const harness = sessionHarness(); + const items = Array.from({length: count}, (_, index) => index + 1); + const done = paginate({ + interaction: harness.interaction, + items, + perPage, + renderPage: (page, number, total) => [{type: 10, content: `[${page.join(",")}] ${number}/${total}`}] + }); + + const latest = () => harness.shown.at(-1) as Rendered; + return { + harness, + done, + label: () => String(latest().components?.[0]?.content), + buttons: () => latest().components?.[1]?.components ?? [], + flags: () => Number(latest().flags ?? 0) + }; +} + + +describe("paginate", () => { + test("starts on page one and disables the backward controls", async () => { + const p = paginated(23); + await p.harness.press("noop"); + expect(p.label()).toBe("[1,2,3,4,5,6,7,8,9,10] 1/3"); + expect(p.buttons()[0]?.disabled).toBe(true); + expect(p.buttons()[1]?.disabled).toBe(true); + await p.harness.end(); + await p.done; + }); + + test("walks forwards and backwards, and clamps at both ends", async () => { + const p = paginated(23); + await p.harness.press("next"); + expect(p.label()).toBe("[11,12,13,14,15,16,17,18,19,20] 2/3"); + await p.harness.press("next"); + expect(p.label()).toBe("[21,22,23] 3/3"); + await p.harness.press("next"); + expect(p.label()).toBe("[21,22,23] 3/3"); + await p.harness.press("first"); + expect(p.label()).toBe("[1,2,3,4,5,6,7,8,9,10] 1/3"); + await p.harness.press("previous"); + expect(p.label()).toBe("[1,2,3,4,5,6,7,8,9,10] 1/3"); + await p.harness.press("last"); + expect(p.label()).toBe("[21,22,23] 3/3"); + await p.harness.end(); + await p.done; + }); + + test("the page counter tracks the current page", async () => { + const p = paginated(23); + await p.harness.press("last"); + expect(p.buttons()[2]?.label).toBe("Page 3 of 3"); + await p.harness.end(); + await p.done; + }); + + test("an empty list is one page, not zero", async () => { + const p = paginated(0); + await p.harness.press("noop"); + expect(p.buttons()[2]?.label).toBe("Page 1 of 1"); + expect(p.buttons().every(button => button.disabled)).toBe(true); + await p.harness.end(); + await p.done; + }); + + /** The previous implementation dropped this flag on the final edit. */ + test("every render carries IsComponentsV2, including the last", async () => { + const p = paginated(23); + await p.harness.press("next"); + await p.harness.end(); + await p.done; + expect(p.harness.shown.every(shown => Number((shown as Rendered).flags) === IS_COMPONENTS_V2)).toBe(true); + }); + + test("all controls are disabled once the collector ends", async () => { + const p = paginated(23); + await p.harness.press("next"); + await p.harness.end(); + await p.done; + expect(p.buttons().every(button => button.disabled)).toBe(true); + }); +}); diff --git a/tests/regressions.test.ts b/tests/regressions.test.ts new file mode 100644 index 0000000..f457f66 --- /dev/null +++ b/tests/regressions.test.ts @@ -0,0 +1,99 @@ +import {describe, expect, test} from "bun:test"; +import path from "node:path"; +import {hasDisallowedChars} from "../src/util/names"; +import {isStoreOpen} from "../src/db"; +import {loadCommands} from "../src/framework"; +import config from "../src/config"; +import * as notices from "../src/util/notices"; + + +/** + * One test per bug fixed during the refactor, so none of them can come back + * quietly. Each names the failure it guards against. + */ + +describe("display-name checks are stateless (was: /cleanname server skipped members)", () => { + const dirty = ["𝓑𝓪𝓭𝓝𝓪𝓶𝓮", "AlsoBad☆", "Bad♥Three", "Bad♦Four", "Ω", "naïve", "🎭🎭🎭"]; + const clean = ["Zerebos", "some_user", "a-b.c", "plain name", "123", "A_B-C.D"]; + + // The regex was module-level with a /g flag. RegExp.test advances lastIndex + // on a global regex, so consecutive calls returned alternating answers. + test("every disallowed name is caught, on every pass", () => { + for (let pass = 0; pass < 3; pass++) { + for (const name of dirty) expect(hasDisallowedChars(name)).toBe(true); + } + }); + + test("every allowed name passes, on every pass", () => { + for (let pass = 0; pass < 3; pass++) { + for (const name of clean) expect(hasDisallowedChars(name)).toBe(false); + } + }); + + test("the same input gives the same answer twenty times running", () => { + const answers = new Set(Array.from({length: 20}, () => hasDisallowedChars("Bad♥Three"))); + expect([...answers]).toEqual([true]); + }); +}); + + +describe("config (was: snowflakes inline in six files)", () => { + test("every id is a plausible snowflake", () => { + const ids = [ + config.guilds.betterDiscord, + config.roles.pluginDeveloper, + config.roles.themeDeveloper, + config.roles.communityPluginDeveloper, + config.roles.communityThemeDeveloper, + config.channels.accountIssues, + config.automod.spamLinkRule + ]; + for (const id of ids) expect(id).toMatch(/^\d{15,25}$/); + }); + + test("ids are distinct, so none of them is a copy-paste slip", () => { + const roles = Object.values(config.roles); + expect(new Set(roles).size).toBe(roles.length); + }); +}); + + +describe("notices satisfy every send path (was: casts at each call site)", () => { + // The djsx widgets were typed as an intersection that satisfied neither + // reply nor editReply, so every call site needed `as MessageOptions`. + test("a notice is a plain object with numeric flags and container components", () => { + const notice = notices.success("done", {ephemeral: true}); + expect(typeof notice.flags).toBe("number"); + expect(Array.isArray(notice.components)).toBe(true); + expect(notice.components).toHaveLength(1); + }); + + test("interpolation happens in the template, not in markup", () => { + // The JSX version emitted a literal "$" here: `${...}` inside JSX text + // is not interpolated. + const isUpdating = true; + const notice = notices.success(`Tag \`hello\` has been ${isUpdating ? "updated" : "created"} successfully!`); + const content = String((notice.components[0] as unknown as {components: Array<{content: string}>}).components[0].content); + expect(content).toContain("has been updated successfully!"); + expect(content).not.toContain("$"); + }); +}); + + +describe("loading commands does not open the database (was: NAPI crash in CI)", () => { + /** + * Every command module imports src/db transitively. The store used to be + * built at import, so merely listing the commands opened SQLite and wrote + * settings.sqlite3 — and pulled the sqlite3 native addon into the test + * process, where it intermittently aborted the runner at exit with a NAPI + * panic after all 96 tests had passed. + * + * Nothing in the suite calls a database method, so this stays false. If a + * future test does open the store, this will fail; that is the signal, and + * such a test should mock src/db instead. + */ + test("reading command metadata leaves the store unopened", async () => { + await loadCommands(path.join(import.meta.dir, "..", "src", "commands")); + expect(isStoreOpen()).toBe(false); + }); +}); diff --git a/tests/session.test.ts b/tests/session.test.ts new file mode 100644 index 0000000..8a9f662 --- /dev/null +++ b/tests/session.test.ts @@ -0,0 +1,89 @@ +import {describe, expect, test} from "bun:test"; +import {isSessionId, runSession, sessionId} from "../src/framework/session"; +import {sessionHarness} from "./helpers/session"; + + +describe("session ids", () => { + test("are namespaced so the dispatcher can tell them apart", () => { + expect(isSessionId(sessionId("next"))).toBe(true); + expect(isSessionId("selfroles.open:user")).toBe(false); + }); +}); + + +describe("runSession", () => { + const counter = (harness: ReturnType) => runSession({ + interaction: harness.interaction, + initial: 1, + render: (n, {ended}) => ({content: `n=${n} ended=${ended}`}), + reduce: (action, n) => action === "inc" ? n + 1 : action === "dec" ? n - 1 : undefined + }); + + test("renders the initial state immediately", async () => { + const harness = sessionHarness(); + const done = counter(harness); + await harness.press("noop"); + expect(harness.shown[0]).toEqual({content: "n=1 ended=false"}); + await harness.end(); + await done; + }); + + test("applies each action in order", async () => { + const harness = sessionHarness(); + const done = counter(harness); + await harness.press("inc"); + await harness.press("inc"); + await harness.press("dec"); + await harness.end(); + expect(await done).toBe(2); + }); + + test("an unrecognised action acknowledges without changing state", async () => { + const harness = sessionHarness(); + const done = counter(harness); + const before = harness.shown.length; + await harness.press("mystery"); + expect(harness.shown).toHaveLength(before); + await harness.end(); + expect(await done).toBe(1); + }); + + /** + * The old Paginator assigned `this.buttonInteraction = i` before checking + * the user, so anyone could redirect someone else's menu. + */ + test("a different user cannot drive the menu", async () => { + const harness = sessionHarness("owner"); + const done = counter(harness); + await harness.press("inc"); + + const refusals = await harness.press("inc", {userId: "someone-else"}); + expect(String(refusals[0]?.content)).toContain("belongs to someone else"); + + await harness.end(); + expect(await done).toBe(2); + }); + + test("audience:anyone opts out of the ownership check", async () => { + const harness = sessionHarness("owner"); + const done = runSession({ + interaction: harness.interaction, + initial: 0, + audience: "anyone", + render: n => ({content: `n=${n}`}), + reduce: (action, n) => action === "inc" ? n + 1 : undefined + }); + expect(await harness.press("inc", {userId: "a-stranger"})).toEqual([]); + await harness.end(); + expect(await done).toBe(1); + }); + + test("the final render is marked ended so controls can be disabled", async () => { + const harness = sessionHarness(); + const done = counter(harness); + await harness.press("inc"); + await harness.end(); + await done; + expect(harness.shown.at(-1)).toEqual({content: "n=2 ended=true"}); + }); +}); diff --git a/tsconfig.bun.json b/tsconfig.bun.json deleted file mode 100644 index da663aa..0000000 --- a/tsconfig.bun.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - // Override the jsx setting for Bun, which doesn't support the "react-jsx" setting - // But when doing this in bunfig.toml, it doesn't seem to work, so we have to do it here instead - "jsx": "preserve" - } -} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 9a757d3..6aefde0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,9 +5,6 @@ "target": "ESNext", "module": "ESNext", "moduleDetection": "force", - // "jsx": "preserve", - "jsx": "react-jsx", - "jsxImportSource": "@djsx", "allowJs": false, // Bundler mode @@ -18,6 +15,7 @@ "verbatimModuleSyntax": true, "noEmit": true, "resolveJsonModule": true, + "types": ["bun"], // Best practices "strict": true, @@ -35,16 +33,12 @@ "paths": { "@": ["./src/index.ts"], "@/*": ["./src/*"], - "@djsx": ["./djsx/index.ts"], - "@djsx/*": ["./djsx/*"], } }, "include": [ "src/**/*", "scripts/*", "tests/**/*", - "djsx/**/*", - "djsx/*", // "debug/test.tsx", // "debug/test2.tsx" ],