From 932cfc57d3e9e7af7fd34b2e2ef7d8341d4eea94 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Thu, 16 Jul 2026 15:38:43 +1200 Subject: [PATCH] Add Everything-Bot Discord bot with reaction roles Initial setup of a modular Discord bot featuring reaction-role management. Includes slash commands for creating, listing, removing, and clearing reaction roles with support for unicode and custom emojis. Features persistent SQLite storage, automatic command and event loading, graceful shutdown handling, and structured logging. Designed with extensibility in mind for future commands and events. --- Everything-Bot/.env.example | 12 + Everything-Bot/.gitignore | 15 + Everything-Bot/README.md | 307 +++ Everything-Bot/eslint.config.js | 33 + Everything-Bot/package-lock.json | 1646 +++++++++++++++++ Everything-Bot/package.json | 30 + .../reactionRoles/clearReactionRoles.js | 115 ++ .../reactionRoles/createReactionRole.js | 180 ++ .../reactionRoles/listReactionRoles.js | 151 ++ .../reactionRoles/removeReactionRole.js | 119 ++ Everything-Bot/src/config/environment.js | 45 + Everything-Bot/src/database/database.js | 103 ++ Everything-Bot/src/database/migrations.js | 30 + Everything-Bot/src/deployCommands.js | 77 + .../src/events/interactionCreate.js | 60 + .../src/events/messageReactionAdd.js | 41 + .../src/events/messageReactionRemove.js | 41 + Everything-Bot/src/events/ready.js | 37 + Everything-Bot/src/index.js | 135 ++ .../src/services/reactionRoleService.js | 241 +++ Everything-Bot/src/utilities/channels.js | 71 + Everything-Bot/src/utilities/emoji.js | 150 ++ .../src/utilities/ensureBotReaction.js | 130 ++ Everything-Bot/src/utilities/errors.js | 98 + Everything-Bot/src/utilities/loadCommands.js | 80 + Everything-Bot/src/utilities/loadEvents.js | 76 + Everything-Bot/src/utilities/logger.js | 114 ++ Everything-Bot/src/utilities/permissions.js | 160 ++ .../src/utilities/reactionRoleHandler.js | 176 ++ 29 files changed, 4473 insertions(+) create mode 100644 Everything-Bot/.env.example create mode 100644 Everything-Bot/.gitignore create mode 100644 Everything-Bot/README.md create mode 100644 Everything-Bot/eslint.config.js create mode 100644 Everything-Bot/package-lock.json create mode 100644 Everything-Bot/package.json create mode 100644 Everything-Bot/src/commands/reactionRoles/clearReactionRoles.js create mode 100644 Everything-Bot/src/commands/reactionRoles/createReactionRole.js create mode 100644 Everything-Bot/src/commands/reactionRoles/listReactionRoles.js create mode 100644 Everything-Bot/src/commands/reactionRoles/removeReactionRole.js create mode 100644 Everything-Bot/src/config/environment.js create mode 100644 Everything-Bot/src/database/database.js create mode 100644 Everything-Bot/src/database/migrations.js create mode 100644 Everything-Bot/src/deployCommands.js create mode 100644 Everything-Bot/src/events/interactionCreate.js create mode 100644 Everything-Bot/src/events/messageReactionAdd.js create mode 100644 Everything-Bot/src/events/messageReactionRemove.js create mode 100644 Everything-Bot/src/events/ready.js create mode 100644 Everything-Bot/src/index.js create mode 100644 Everything-Bot/src/services/reactionRoleService.js create mode 100644 Everything-Bot/src/utilities/channels.js create mode 100644 Everything-Bot/src/utilities/emoji.js create mode 100644 Everything-Bot/src/utilities/ensureBotReaction.js create mode 100644 Everything-Bot/src/utilities/errors.js create mode 100644 Everything-Bot/src/utilities/loadCommands.js create mode 100644 Everything-Bot/src/utilities/loadEvents.js create mode 100644 Everything-Bot/src/utilities/logger.js create mode 100644 Everything-Bot/src/utilities/permissions.js create mode 100644 Everything-Bot/src/utilities/reactionRoleHandler.js diff --git a/Everything-Bot/.env.example b/Everything-Bot/.env.example new file mode 100644 index 0000000..6b9ae79 --- /dev/null +++ b/Everything-Bot/.env.example @@ -0,0 +1,12 @@ +# Discord bot token from the Developer Portal +DISCORD_TOKEN= + +# Application (client) ID from the Developer Portal +DISCORD_CLIENT_ID= + +# Optional: development guild ID for fast slash-command updates. +# Leave empty to deploy commands globally (may take up to 1 hour to propagate). +DISCORD_GUILD_ID= + +# Path to the SQLite database file +DATABASE_PATH=./data/bot.sqlite diff --git a/Everything-Bot/.gitignore b/Everything-Bot/.gitignore new file mode 100644 index 0000000..f6f8752 --- /dev/null +++ b/Everything-Bot/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +.env +data/ +*.sqlite +*.sqlite-journal +*.sqlite-wal +*.sqlite-shm +.DS_Store +Thumbs.db +*.log +.idea/ +.vscode/ +*.tmp +coverage/ +dist/ diff --git a/Everything-Bot/README.md b/Everything-Bot/README.md new file mode 100644 index 0000000..55d4feb --- /dev/null +++ b/Everything-Bot/README.md @@ -0,0 +1,307 @@ +# Everything Bot + +Modular Discord bot built with Node.js, Discord.js, and SQLite. The first feature is **reaction roles**, with an architecture designed so new commands and events can be added without rewriting the core. + +## Requirements + +- **Node.js** 20 or newer (Node 22 recommended) +- A Discord application and bot user +- npm + +## Features + +- Guild-only slash commands for creating, listing, removing, and clearing reaction roles +- Unicode, static custom, and animated custom emoji support +- Multiple mappings per message, shared roles across messages, multi-guild storage +- Persistent SQLite storage that survives restarts +- Automatic command and event loading from folders +- Graceful shutdown and structured logging (secrets never logged) + +## Installation + +```bash +npm install +cp .env.example .env +``` + +Edit `.env` with your Discord credentials (see below). + +## Discord application setup + +1. Open the [Discord Developer Portal](https://discord.com/developers/applications). +2. Click **New Application**, name it, and create it. +3. Open the **Bot** tab and click **Add Bot** if needed. +4. Under **Token**, click **Reset Token** / **Copy** and save it for `DISCORD_TOKEN`. +5. Open the **OAuth2 → General** tab and copy the **Application ID** for `DISCORD_CLIENT_ID`. + +### Required gateway intents + +In the **Bot** tab, enable: + +| Intent | Required | Notes | +| --- | --- | --- | +| Server Members Intent | Yes | Needed to fetch members and assign/remove roles | +| Message Content Intent | No | Not required for reaction roles | + +The bot also requests these gateway intents in code: + +- Guilds +- Guild Members +- Guild Message Reactions + +### Required bot permissions + +When inviting the bot, grant at least: + +```text +View Channels +Read Message History +Add Reactions +Manage Roles +Use Application Commands +``` + +**Role hierarchy:** the bot’s highest role must sit **above** every role it needs to assign. In **Server Settings → Roles**, drag the bot’s role above those roles. + +### Invite the bot + +1. Open **OAuth2 → URL Generator**. +2. Scopes: `bot` and `applications.commands`. +3. Bot permissions: select the permissions listed above (or use permission integer `268438560` as a starting point — verify in the UI). +4. Open the generated URL, choose your server, and authorize. + +## Environment variables + +Copy `.env.example` to `.env`: + +```env +DISCORD_TOKEN= +DISCORD_CLIENT_ID= +DISCORD_GUILD_ID= +DATABASE_PATH=./data/bot.sqlite +``` + +| Variable | Required | Description | +| --- | --- | --- | +| `DISCORD_TOKEN` | Yes | Bot token | +| `DISCORD_CLIENT_ID` | Yes | Application ID | +| `DISCORD_GUILD_ID` | No | Development server ID for fast command updates | +| `DATABASE_PATH` | No | SQLite file path (default `./data/bot.sqlite`) | + +The bot validates required variables on startup and exits with a clear error if any are missing. + +## Database + +SQLite is initialized automatically on startup. The `data/` directory and database file are created if missing. No manual migration step is required. + +The `reaction_roles` table stores: + +- `id`, `guild_id`, `channel_id`, `message_id`, `role_id` +- `emoji_identifier`, `emoji_display` +- `created_by`, `created_at` + +A uniqueness constraint on `(guild_id, message_id, emoji_identifier)` prevents duplicate emoji mappings on the same message. + +## Slash command deployment + +```bash +npm run deploy-commands +``` + +- **With `DISCORD_GUILD_ID` set:** commands are registered to that guild and usually appear within a few seconds. Prefer this while developing. +- **Without `DISCORD_GUILD_ID`:** commands are registered globally and may take up to about an hour to propagate to all servers. + +Re-run deployment whenever you add or change slash command definitions. + +## Running the bot + +Development (restarts on file changes): + +```bash +npm run dev +``` + +Production: + +```bash +npm start +``` + +Lint: + +```bash +npm run lint +``` + +## Reaction-role commands + +All of these require **Manage Roles** and are guild-only. Responses are ephemeral. + +### Create + +```text +/reaction-role message_id: role: emoji: +``` + +Example: + +```text +/reaction-role message_id:123456789012345678 role:@Game Updates emoji:🎮 +``` + +The command: + +1. Checks Manage Roles for the user +2. Finds the message in the **current channel** +3. Validates the role and bot hierarchy / permissions +4. Rejects integration-managed roles +5. Validates the emoji and adds the bot’s reaction +6. Saves the mapping in SQLite +7. Confirms with message, role, and emoji + +### List + +```text +/reaction-role-list +``` + +Shows all mappings for the current server in an embed (channel, message ID, emoji, role), with pagination when needed. + +### Remove one mapping + +```text +/reaction-role-remove message_id: emoji: +``` + +Deletes the mapping and attempts to remove **only the bot’s** reaction. + +### Clear a message + +```text +/reaction-role-clear message_id: +``` + +Deletes every mapping for that message and attempts to remove the bot’s configured reactions. + +## Custom emoji syntax + +| Type | Example | Stored identifier | +| --- | --- | --- | +| Unicode | `🎮` | The character itself | +| Static custom | `<:gaming:123456789012345678>` | Custom emoji ID | +| Animated custom | `` | Custom emoji ID | + +To insert custom emoji markup in the command: in Discord, type `\:emojiName:` in chat to reveal the raw form, then paste it into the `emoji` option. + +Custom emojis must be available to the bot (from a server the bot shares). Matching always uses the stored identifier so unicode and custom emojis stay consistent across restarts. + +## Discord role hierarchy + +Discord only allows a bot to assign roles **strictly below** its highest role. If assignment fails: + +1. Open **Server Settings → Roles** +2. Move the bot’s role above the target role +3. Confirm the bot still has **Manage Roles** +4. Confirm the target role is not managed by an integration (boosts, bots, Linked Roles, etc.) + +The bot also refuses to assign the `@everyone` role and roles managed by integrations. + +## Project structure + +```text +src/ + commands/ + reactionRoles/ + createReactionRole.js + listReactionRoles.js + removeReactionRole.js + clearReactionRoles.js + events/ + interactionCreate.js + messageReactionAdd.js + messageReactionRemove.js + ready.js + services/ + reactionRoleService.js + database/ + database.js + migrations.js + utilities/ + emoji.js + permissions.js + logger.js + errors.js + loadCommands.js + loadEvents.js + reactionRoleHandler.js + config/ + environment.js + deployCommands.js + index.js +``` + +Responsibilities are split across commands, events, services, database, utilities, and config so features stay isolated. + +## Adding future commands + +1. Create a new file under `src/commands/` (any subfolder). +2. Export a default object: + +```javascript +import { SlashCommandBuilder } from 'discord.js'; + +export default { + data: new SlashCommandBuilder() + .setName('ping') + .setDescription('Replies with Pong'), + async execute(interaction) { + await interaction.reply('Pong!'); + }, +}; +``` + +3. Run `npm run deploy-commands`. +4. Restart the bot if it is already running. + +Commands are discovered automatically — no central switch statement to edit. + +## Adding future event handlers + +1. Create a file under `src/events/`. +2. Export a default object: + +```javascript +import { Events } from 'discord.js'; + +export default { + name: Events.GuildCreate, + once: false, + async execute(guild) { + // ... + }, +}; +``` + +Set `once: true` for one-time events such as `ClientReady`. Events are registered automatically on startup. + +## Troubleshooting + +| Problem | What to try | +| --- | --- | +| Commands do not appear | Run `npm run deploy-commands`. For global commands, wait up to an hour, or set `DISCORD_GUILD_ID` for instant guild deploy. | +| “Message not found” | Use a message in the **same channel** where you run the command. Enable Developer Mode to copy the message ID. | +| `invalid ELF header` / better-sqlite3 crash | `node_modules` was installed on a different OS (e.g. Windows) then copied to Linux. On the **server**, run `rm -rf node_modules && npm install`. Do not copy `node_modules` between machines. | +| Roles are not assigned | Enable **Server Members Intent**. Ensure the bot role is above the target role and has Manage Roles. | +| Custom emoji rejected | The bot must be in a server that has that emoji. Paste full `<:name:id>` / `` markup. | +| Duplicate mapping error | That emoji is already configured on the message — remove it first or pick another emoji. | +| Bot cannot react | Grant **Add Reactions**, **View Channels**, and **Read Message History** in that channel. | +| Database errors | Ensure the process can write to `DATABASE_PATH` (default `./data/`). | +| Login fails | Check `DISCORD_TOKEN` in `.env` (no quotes/spaces). Reset the token in the Developer Portal if needed. | + +## Graceful shutdown + +The bot handles `SIGINT`, `SIGTERM`, unhandled rejections, and uncaught exceptions. On shutdown it stops accepting new work, destroys the Discord client, closes SQLite, and logs completion. + +## License + +MIT diff --git a/Everything-Bot/eslint.config.js b/Everything-Bot/eslint.config.js new file mode 100644 index 0000000..11d7474 --- /dev/null +++ b/Everything-Bot/eslint.config.js @@ -0,0 +1,33 @@ +export default [ + { + ignores: ['node_modules/**', 'data/**'], + }, + { + files: ['src/**/*.js'], + languageOptions: { + ecmaVersion: 2024, + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + }, + }, + rules: { + 'no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + 'no-undef': 'error', + 'no-console': 'off', + 'prefer-const': 'error', + 'no-var': 'error', + eqeqeq: ['error', 'always'], + curly: ['error', 'all'], + 'no-throw-literal': 'error', + }, + }, +]; diff --git a/Everything-Bot/package-lock.json b/Everything-Bot/package-lock.json new file mode 100644 index 0000000..fec0b74 --- /dev/null +++ b/Everything-Bot/package-lock.json @@ -0,0 +1,1646 @@ +{ + "name": "everything-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "everything-bot", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "better-sqlite3": "^12.11.1", + "discord.js": "^14.27.0", + "dotenv": "^17.4.2" + }, + "devDependencies": { + "eslint": "^10.7.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.2.tgz", + "integrity": "sha512-c5HI3hJuRWWrnpyCZAUYfIUTAEv9weX4tE2UfDqtM3ztizdKE9RFEf3G5IWhPyO+kPldUDpGv02XJAQzdUTC9Q==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.49", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.50", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.50.tgz", + "integrity": "sha512-J2n/bpIETX3DQ6AJ7/0xbsTLmYiJQtO/LKcXKC1YDbB56OUwtDbdXOFE8Q4g8jVGHBR2VAy1+D4ngaIgkMNV9w==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.27.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz", + "integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.2", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.5", + "discord-api-types": "^0.38.49", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/Everything-Bot/package.json b/Everything-Bot/package.json new file mode 100644 index 0000000..3e63d27 --- /dev/null +++ b/Everything-Bot/package.json @@ -0,0 +1,30 @@ +{ + "name": "everything-bot", + "version": "1.0.0", + "description": "Modular Discord bot with reaction roles and extensible architecture", + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js", + "deploy-commands": "node src/deployCommands.js", + "lint": "eslint src" + }, + "keywords": [ + "discord", + "bot", + "reaction-roles", + "discord.js" + ], + "license": "MIT", + "dependencies": { + "better-sqlite3": "^12.11.1", + "discord.js": "^14.27.0", + "dotenv": "^17.4.2" + }, + "devDependencies": { + "eslint": "^10.7.0" + } +} diff --git a/Everything-Bot/src/commands/reactionRoles/clearReactionRoles.js b/Everything-Bot/src/commands/reactionRoles/clearReactionRoles.js new file mode 100644 index 0000000..4fef69d --- /dev/null +++ b/Everything-Bot/src/commands/reactionRoles/clearReactionRoles.js @@ -0,0 +1,115 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + MessageFlags, +} from 'discord.js'; +import { getEmojiIdentifier } from '../../utilities/emoji.js'; +import { requireManageRoles } from '../../utilities/permissions.js'; +import { resolveInteractionGuild } from '../../utilities/channels.js'; +import { reactionRoleService } from '../../services/reactionRoleService.js'; +import { + replyEphemeralError, + describeCommandError, +} from '../../utilities/errors.js'; +import logger from '../../utilities/logger.js'; + +export default { + data: new SlashCommandBuilder() + .setName('reaction-role-clear') + .setDescription('Remove all reaction-role mappings from a message') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + .setDMPermission(false) + .addStringOption((option) => + option + .setName('message_id') + .setDescription('ID of the message to clear') + .setRequired(true), + ), + + /** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ + async execute(interaction) { + if (!(await requireManageRoles(interaction))) { + return; + } + + const guild = await resolveInteractionGuild(interaction); + if (!guild) { + await replyEphemeralError( + interaction, + 'Could not access this server. Make sure the bot is online and has been invited here.', + ); + return; + } + + const messageId = interaction.options.getString('message_id', true).trim(); + + if (!/^\d{17,20}$/.test(messageId)) { + await replyEphemeralError(interaction, 'Invalid message ID.'); + return; + } + + let removed; + try { + removed = reactionRoleService.clearMessage(guild.id, messageId); + } catch (error) { + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + if (removed.length === 0) { + await replyEphemeralError( + interaction, + describeCommandError(new Error('NO_MAPPINGS_FOR_MESSAGE')), + ); + return; + } + + const channelId = removed[0].channel_id; + + try { + const channel = + guild.channels.cache.get(channelId) ?? + (await guild.channels.fetch(channelId).catch(() => null)); + + if (channel?.isTextBased()) { + const message = await channel.messages.fetch(messageId).catch(() => null); + if (message && interaction.client.user) { + for (const mapping of removed) { + try { + const reaction = message.reactions.cache.find((entry) => { + const identifier = getEmojiIdentifier(entry.emoji); + return identifier === mapping.emoji_identifier; + }); + + if (reaction) { + await reaction.users.remove(interaction.client.user.id); + } + } catch (error) { + logger.warn('Failed to remove bot reaction while clearing mappings', { + messageId, + emoji: mapping.emoji_display, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + } + } catch (error) { + logger.warn('Failed to clean up reactions after clearing mappings', { + messageId, + error: error instanceof Error ? error.message : String(error), + }); + } + + const emojiList = removed.map((row) => row.emoji_display).join(' '); + + await interaction.reply({ + content: + `Cleared **${removed.length}** reaction-role mapping(s) from message \`${messageId}\`.\n` + + `Emojis: ${emojiList}`, + flags: MessageFlags.Ephemeral, + }); + }, +}; diff --git a/Everything-Bot/src/commands/reactionRoles/createReactionRole.js b/Everything-Bot/src/commands/reactionRoles/createReactionRole.js new file mode 100644 index 0000000..d45860a --- /dev/null +++ b/Everything-Bot/src/commands/reactionRoles/createReactionRole.js @@ -0,0 +1,180 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + MessageFlags, +} from 'discord.js'; +import { parseEmoji, isCustomEmojiAvailable } from '../../utilities/emoji.js'; +import { requireManageRoles, canBotManageRole } from '../../utilities/permissions.js'; +import { ensureBotReaction } from '../../utilities/ensureBotReaction.js'; +import { + resolveInteractionGuild, + resolveInteractionTextChannel, +} from '../../utilities/channels.js'; +import { reactionRoleService } from '../../services/reactionRoleService.js'; +import { + replyEphemeralError, + describeCommandError, + isDatabaseError, +} from '../../utilities/errors.js'; +import logger from '../../utilities/logger.js'; + +export default { + data: new SlashCommandBuilder() + .setName('reaction-role') + .setDescription('Create a reaction role on a message in this channel') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + .setDMPermission(false) + .addStringOption((option) => + option + .setName('message_id') + .setDescription('ID of the message in this channel') + .setRequired(true), + ) + .addRoleOption((option) => + option + .setName('role') + .setDescription('Role to assign when the reaction is added') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('emoji') + .setDescription('Unicode emoji or custom emoji markup') + .setRequired(true), + ), + + /** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ + async execute(interaction) { + if (!(await requireManageRoles(interaction))) { + return; + } + + const guild = await resolveInteractionGuild(interaction); + if (!guild) { + await replyEphemeralError( + interaction, + 'Could not access this server. Make sure the bot is online and has been invited here.', + ); + return; + } + + const channel = await resolveInteractionTextChannel(interaction); + if (!channel) { + await replyEphemeralError( + interaction, + 'This command can only be used in a text channel. Make sure the bot can view this channel.', + ); + return; + } + + const messageId = interaction.options.getString('message_id', true).trim(); + const role = interaction.options.getRole('role', true); + const emojiInput = interaction.options.getString('emoji', true); + + if (!/^\d{17,20}$/.test(messageId)) { + await replyEphemeralError( + interaction, + 'Invalid message ID. Right-click the message → Copy Message ID (Developer Mode must be enabled).', + ); + return; + } + + const parsedEmoji = parseEmoji(emojiInput); + if (!parsedEmoji) { + await replyEphemeralError(interaction, describeCommandError(new Error('INVALID_EMOJI'))); + return; + } + + if (parsedEmoji.type === 'custom' && !isCustomEmojiAvailable(interaction.client, parsedEmoji.id)) { + await replyEphemeralError( + interaction, + describeCommandError(new Error('CUSTOM_EMOJI_UNAVAILABLE')), + ); + return; + } + + // Role option may return APIRole; resolve the full Role when possible + const guildRole = + 'position' in role && typeof role.position === 'number' + ? role + : await guild.roles.fetch(role.id).catch(() => null); + + if (!guildRole) { + await replyEphemeralError(interaction, describeCommandError(new Error('ROLE_NOT_FOUND'))); + return; + } + + const permissionCheck = canBotManageRole(guild, guildRole); + if (!permissionCheck.ok) { + await replyEphemeralError(interaction, permissionCheck.reason); + return; + } + + let message; + try { + message = await channel.messages.fetch(messageId); + } catch (error) { + logger.warn('Failed to fetch target message for reaction role', { + messageId, + channelId: channel.id, + error: error instanceof Error ? error.message : String(error), + }); + await replyEphemeralError(interaction, describeCommandError(new Error('MESSAGE_NOT_FOUND'))); + return; + } + + try { + // Add the emoji if it is missing (or if the bot has not reacted yet) + await ensureBotReaction(message, parsedEmoji.identifier, parsedEmoji.reactValue); + } catch (error) { + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + try { + reactionRoleService.create({ + guildId: guild.id, + channelId: channel.id, + messageId: message.id, + roleId: guildRole.id, + emojiIdentifier: parsedEmoji.identifier, + emojiDisplay: parsedEmoji.display, + createdBy: interaction.user.id, + }); + } catch (error) { + // Best-effort cleanup of the bot reaction if persistence fails + try { + const botReaction = message.reactions.cache.find((reaction) => { + const id = reaction.emoji.id ?? reaction.emoji.name; + return id === parsedEmoji.identifier; + }); + if (botReaction) { + await botReaction.users.remove(interaction.client.user.id); + } + } catch (cleanupError) { + logger.warn('Failed to clean up reaction after DB error', { + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); + } + + if (isDatabaseError(error) && error.message !== 'DUPLICATE_MAPPING') { + await replyEphemeralError(interaction, 'A database error occurred while saving the reaction role.'); + return; + } + + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + await interaction.reply({ + content: + `Reaction role created.\n` + + `• Message: [${message.id}](${message.url})\n` + + `• Role: ${guildRole}\n` + + `• Emoji: ${parsedEmoji.display}`, + flags: MessageFlags.Ephemeral, + }); + }, +}; diff --git a/Everything-Bot/src/commands/reactionRoles/listReactionRoles.js b/Everything-Bot/src/commands/reactionRoles/listReactionRoles.js new file mode 100644 index 0000000..629b0e9 --- /dev/null +++ b/Everything-Bot/src/commands/reactionRoles/listReactionRoles.js @@ -0,0 +1,151 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + MessageFlags, +} from 'discord.js'; +import { requireManageRoles } from '../../utilities/permissions.js'; +import { resolveInteractionGuild } from '../../utilities/channels.js'; +import { reactionRoleService } from '../../services/reactionRoleService.js'; +import { replyEphemeralError, describeCommandError } from '../../utilities/errors.js'; +import logger from '../../utilities/logger.js'; + +const PAGE_SIZE = 10; + +export default { + data: new SlashCommandBuilder() + .setName('reaction-role-list') + .setDescription('List all reaction-role mappings in this server') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + .setDMPermission(false), + + /** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ + async execute(interaction) { + if (!(await requireManageRoles(interaction))) { + return; + } + + const guild = await resolveInteractionGuild(interaction); + if (!guild) { + await replyEphemeralError( + interaction, + 'Could not access this server. Make sure the bot is online and has been invited here.', + ); + return; + } + + let mappings; + try { + mappings = reactionRoleService.listByGuild(guild.id); + } catch (error) { + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + if (mappings.length === 0) { + await interaction.reply({ + content: 'No reaction-role mappings are configured in this server.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const totalPages = Math.ceil(mappings.length / PAGE_SIZE); + let page = 0; + + const buildEmbed = (pageIndex) => { + const start = pageIndex * PAGE_SIZE; + const slice = mappings.slice(start, start + PAGE_SIZE); + + const lines = slice.map((row, index) => { + const number = start + index + 1; + return ( + `**${number}.** <#${row.channel_id}> · \`${row.message_id}\`\n` + + `  ${row.emoji_display} → <@&${row.role_id}>` + ); + }); + + return new EmbedBuilder() + .setTitle('Reaction roles') + .setDescription(lines.join('\n\n')) + .setColor(0x5865f2) + .setFooter({ + text: `Page ${pageIndex + 1} of ${totalPages} · ${mappings.length} total`, + }) + .setTimestamp(); + }; + + const buildRow = (pageIndex) => + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('rr_list_prev') + .setLabel('Previous') + .setStyle(ButtonStyle.Secondary) + .setDisabled(pageIndex === 0), + new ButtonBuilder() + .setCustomId('rr_list_next') + .setLabel('Next') + .setStyle(ButtonStyle.Secondary) + .setDisabled(pageIndex >= totalPages - 1), + ); + + /** @type {import('discord.js').InteractionReplyOptions} */ + const response = { + embeds: [buildEmbed(page)], + flags: MessageFlags.Ephemeral, + }; + + if (totalPages > 1) { + response.components = [buildRow(page)]; + } + + await interaction.reply(response); + + if (totalPages <= 1) { + return; + } + + const message = await interaction.fetchReply(); + + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 5 * 60 * 1000, + filter: (buttonInteraction) => buttonInteraction.user.id === interaction.user.id, + }); + + collector.on('collect', async (buttonInteraction) => { + if (buttonInteraction.customId === 'rr_list_prev') { + page = Math.max(0, page - 1); + } else if (buttonInteraction.customId === 'rr_list_next') { + page = Math.min(totalPages - 1, page + 1); + } + + try { + await buttonInteraction.update({ + embeds: [buildEmbed(page)], + components: [buildRow(page)], + }); + } catch (error) { + logger.warn('Failed to update reaction-role list pagination', { + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + collector.on('end', async () => { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + logger.debug('Could not clear list pagination buttons', { + error: error instanceof Error ? error.message : String(error), + }); + } + }); + }, +}; diff --git a/Everything-Bot/src/commands/reactionRoles/removeReactionRole.js b/Everything-Bot/src/commands/reactionRoles/removeReactionRole.js new file mode 100644 index 0000000..dab89a1 --- /dev/null +++ b/Everything-Bot/src/commands/reactionRoles/removeReactionRole.js @@ -0,0 +1,119 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + MessageFlags, +} from 'discord.js'; +import { parseEmoji, getEmojiIdentifier } from '../../utilities/emoji.js'; +import { requireManageRoles } from '../../utilities/permissions.js'; +import { resolveInteractionGuild } from '../../utilities/channels.js'; +import { reactionRoleService } from '../../services/reactionRoleService.js'; +import { + replyEphemeralError, + describeCommandError, +} from '../../utilities/errors.js'; +import logger from '../../utilities/logger.js'; + +export default { + data: new SlashCommandBuilder() + .setName('reaction-role-remove') + .setDescription('Remove a reaction-role mapping from a message') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + .setDMPermission(false) + .addStringOption((option) => + option + .setName('message_id') + .setDescription('ID of the message that has the reaction role') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('emoji') + .setDescription('Emoji used for the reaction role') + .setRequired(true), + ), + + /** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ + async execute(interaction) { + if (!(await requireManageRoles(interaction))) { + return; + } + + const guild = await resolveInteractionGuild(interaction); + if (!guild) { + await replyEphemeralError( + interaction, + 'Could not access this server. Make sure the bot is online and has been invited here.', + ); + return; + } + + const messageId = interaction.options.getString('message_id', true).trim(); + const emojiInput = interaction.options.getString('emoji', true); + + if (!/^\d{17,20}$/.test(messageId)) { + await replyEphemeralError(interaction, 'Invalid message ID.'); + return; + } + + const parsedEmoji = parseEmoji(emojiInput); + if (!parsedEmoji) { + await replyEphemeralError(interaction, describeCommandError(new Error('INVALID_EMOJI'))); + return; + } + + let removed; + try { + removed = reactionRoleService.remove( + guild.id, + messageId, + parsedEmoji.identifier, + ); + } catch (error) { + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + if (!removed) { + await replyEphemeralError(interaction, describeCommandError(new Error('MAPPING_NOT_FOUND'))); + return; + } + + // Attempt to remove only the bot's reaction; leave user reactions alone + try { + const channel = + guild.channels.cache.get(removed.channel_id) ?? + (await guild.channels.fetch(removed.channel_id).catch(() => null)); + + if (channel?.isTextBased()) { + const message = await channel.messages.fetch(messageId).catch(() => null); + if (message) { + const reaction = message.reactions.cache.find((entry) => { + const identifier = getEmojiIdentifier(entry.emoji); + return identifier === removed.emoji_identifier; + }); + + if (reaction && interaction.client.user) { + await reaction.users.remove(interaction.client.user.id); + } + } + } + } catch (error) { + logger.warn('Failed to remove bot reaction after mapping deletion', { + messageId, + emoji: removed.emoji_display, + error: error instanceof Error ? error.message : String(error), + }); + } + + await interaction.reply({ + content: + `Removed reaction role.\n` + + `• Message: \`${messageId}\`\n` + + `• Emoji: ${removed.emoji_display}\n` + + `• Role: <@&${removed.role_id}>`, + flags: MessageFlags.Ephemeral, + }); + }, +}; diff --git a/Everything-Bot/src/config/environment.js b/Everything-Bot/src/config/environment.js new file mode 100644 index 0000000..da19268 --- /dev/null +++ b/Everything-Bot/src/config/environment.js @@ -0,0 +1,45 @@ +import 'dotenv/config'; + +/** + * Validated application configuration loaded from environment variables. + * @typedef {object} EnvironmentConfig + * @property {string} token + * @property {string} clientId + * @property {string|null} guildId + * @property {string} databasePath + */ + +/** + * Reads and validates required environment variables. + * Exits the process with a clear message when required values are missing. + * @returns {EnvironmentConfig} + */ +export function loadEnvironment() { + const required = ['DISCORD_TOKEN', 'DISCORD_CLIENT_ID']; + const missing = required.filter((key) => { + const value = process.env[key]; + return value === undefined || value.trim() === ''; + }); + + if (missing.length > 0) { + console.error( + `Missing required environment variable(s): ${missing.join(', ')}. ` + + 'Copy .env.example to .env and fill in the values.', + ); + process.exit(1); + } + + const guildId = process.env.DISCORD_GUILD_ID?.trim() || null; + + return { + token: process.env.DISCORD_TOKEN.trim(), + clientId: process.env.DISCORD_CLIENT_ID.trim(), + guildId, + databasePath: process.env.DATABASE_PATH?.trim() || './data/bot.sqlite', + }; +} + +/** @type {EnvironmentConfig} */ +const env = loadEnvironment(); + +export default env; diff --git a/Everything-Bot/src/database/database.js b/Everything-Bot/src/database/database.js new file mode 100644 index 0000000..0b2a4f3 --- /dev/null +++ b/Everything-Bot/src/database/database.js @@ -0,0 +1,103 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { runMigrations } from './migrations.js'; +import logger from '../utilities/logger.js'; + +/** @type {import('better-sqlite3').Database | null} */ +let db = null; + +/** + * Opens (or creates) the SQLite database, ensures the parent directory exists, + * and runs schema migrations. + * + * @param {string} databasePath + * @returns {import('better-sqlite3').Database} + */ +export function initDatabase(databasePath) { + if (db) { + return db; + } + + const absolutePath = path.resolve(databasePath); + const directory = path.dirname(absolutePath); + + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + logger.info('Created database directory', { directory }); + } + + try { + db = new Database(absolutePath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + logger.info('Database initialized', { path: absolutePath }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('Database initialization failed', { + error: message, + path: absolutePath, + }); + + if (isNativeModuleMismatch(message)) { + throw new Error( + 'better-sqlite3 native module is incompatible with this system ' + + '(often caused by copying node_modules from another OS). ' + + 'On this machine run: rm -rf node_modules && npm install', + { cause: error }, + ); + } + + throw error; + } + + return db; +} + +/** + * Returns the open database connection. + * @returns {import('better-sqlite3').Database} + */ +export function getDatabase() { + if (!db) { + throw new Error('Database has not been initialized. Call initDatabase() first.'); + } + + return db; +} + +/** + * Closes the SQLite connection if it is open. + */ +export function closeDatabase() { + if (!db) { + return; + } + + try { + db.close(); + logger.info('Database connection closed'); + } catch (error) { + logger.error('Failed to close database', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + db = null; + } +} + +/** + * Detects native addon build mismatches (e.g. Windows binary used on Linux). + * @param {string} message + * @returns {boolean} + */ +function isNativeModuleMismatch(message) { + const lower = message.toLowerCase(); + return ( + lower.includes('invalid elf header') || + lower.includes('not a valid win32 application') || + lower.includes('wrong elf class') || + (lower.includes('cannot find module') && lower.includes('better_sqlite3')) + ); +} diff --git a/Everything-Bot/src/database/migrations.js b/Everything-Bot/src/database/migrations.js new file mode 100644 index 0000000..aea68f2 --- /dev/null +++ b/Everything-Bot/src/database/migrations.js @@ -0,0 +1,30 @@ +/** + * Creates the reaction_roles table and indexes if they do not already exist. + * + * @param {import('better-sqlite3').Database} db + */ +export function runMigrations(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS reaction_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL, + role_id TEXT NOT NULL, + emoji_identifier TEXT NOT NULL, + emoji_display TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (guild_id, message_id, emoji_identifier) + ); + + CREATE INDEX IF NOT EXISTS idx_reaction_roles_lookup + ON reaction_roles (guild_id, message_id, emoji_identifier); + + CREATE INDEX IF NOT EXISTS idx_reaction_roles_guild + ON reaction_roles (guild_id); + + CREATE INDEX IF NOT EXISTS idx_reaction_roles_message + ON reaction_roles (guild_id, message_id); + `); +} diff --git a/Everything-Bot/src/deployCommands.js b/Everything-Bot/src/deployCommands.js new file mode 100644 index 0000000..f319ed8 --- /dev/null +++ b/Everything-Bot/src/deployCommands.js @@ -0,0 +1,77 @@ +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { REST, Routes } from 'discord.js'; +import env from './config/environment.js'; +import { loadCommands } from './utilities/loadCommands.js'; +import logger from './utilities/logger.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Registers slash commands with Discord. + * When DISCORD_GUILD_ID is set, commands are deployed to that guild (fast updates). + * Otherwise commands are deployed globally (may take up to an hour to propagate). + */ +async function deployCommands() { + const commandsPath = path.join(__dirname, 'commands'); + const commands = await loadCommands(commandsPath); + const body = [...commands.values()].map((command) => command.data.toJSON()); + + if (body.length === 0) { + logger.error('No commands found to deploy'); + process.exit(1); + } + + const rest = new REST({ version: '10' }).setToken(env.token); + + try { + if (env.guildId) { + logger.info('Deploying guild commands (updates apply almost immediately)', { + guildId: env.guildId, + commandCount: body.length, + }); + + const result = await rest.put( + Routes.applicationGuildCommands(env.clientId, env.guildId), + { body }, + ); + + logger.info('Guild command registration complete', { + registered: Array.isArray(result) ? result.length : body.length, + }); + console.log( + `Deployed ${body.length} guild command(s) to guild ${env.guildId}. ` + + 'Guild commands update quickly — usually within a few seconds.', + ); + } else { + logger.info('Deploying global commands (propagation may take up to 1 hour)', { + commandCount: body.length, + }); + + const result = await rest.put(Routes.applicationCommands(env.clientId), { body }); + + logger.info('Global command registration complete', { + registered: Array.isArray(result) ? result.length : body.length, + }); + console.log( + `Deployed ${body.length} global command(s). ` + + 'Global command updates may take up to an hour to appear in all servers.', + ); + } + } catch (error) { + logger.error('Command registration failed', { + error: error instanceof Error ? error.message : String(error), + }); + process.exit(1); + } +} + +const isDirectRun = process.argv[1] + && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; + +if (isDirectRun) { + deployCommands(); +} + +export { deployCommands }; diff --git a/Everything-Bot/src/events/interactionCreate.js b/Everything-Bot/src/events/interactionCreate.js new file mode 100644 index 0000000..b9f70d4 --- /dev/null +++ b/Everything-Bot/src/events/interactionCreate.js @@ -0,0 +1,60 @@ +import { Events, MessageFlags } from 'discord.js'; +import logger from '../utilities/logger.js'; +import { replyEphemeralError, describeCommandError } from '../utilities/errors.js'; + +export default { + name: Events.InteractionCreate, + once: false, + + /** + * Routes chat input commands to the matching loaded command module. + * @param {import('discord.js').Interaction} interaction + */ + async execute(interaction) { + if (!interaction.isChatInputCommand()) { + return; + } + + const command = interaction.client.commands.get(interaction.commandName); + + if (!command) { + logger.warn('Received unknown command interaction', { + commandName: interaction.commandName, + }); + await replyEphemeralError( + interaction, + 'That command is not available. It may need to be redeployed.', + ); + return; + } + + try { + logger.info('Executing command', { + commandName: interaction.commandName, + userId: interaction.user.id, + guildId: interaction.guildId, + }); + await command.execute(interaction); + } catch (error) { + logger.error('Command execution error', { + commandName: interaction.commandName, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + + const message = describeCommandError(error); + + try { + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: message, flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ content: message, flags: MessageFlags.Ephemeral }); + } + } catch (replyError) { + logger.error('Failed to send command error response', { + error: replyError instanceof Error ? replyError.message : String(replyError), + }); + } + } + }, +}; diff --git a/Everything-Bot/src/events/messageReactionAdd.js b/Everything-Bot/src/events/messageReactionAdd.js new file mode 100644 index 0000000..77b1cbb --- /dev/null +++ b/Everything-Bot/src/events/messageReactionAdd.js @@ -0,0 +1,41 @@ +import { Events } from 'discord.js'; +import { + resolveReactionContext, + findMappingForReaction, + applyReactionRoleChange, +} from '../utilities/reactionRoleHandler.js'; +import logger from '../utilities/logger.js'; + +export default { + name: Events.MessageReactionAdd, + once: false, + + /** + * @param {import('discord.js').MessageReaction | import('discord.js').PartialMessageReaction} reaction + * @param {import('discord.js').User | import('discord.js').PartialUser} user + */ + async execute(reaction, user) { + try { + if (user.bot) { + return; + } + + const context = await resolveReactionContext(reaction, user); + if (!context) { + return; + } + + const mapping = findMappingForReaction(context.guild, context.message, context.reaction); + if (!mapping) { + return; + } + + await applyReactionRoleChange('add', context.guild, context.user, mapping); + } catch (error) { + logger.error('Unhandled error in messageReactionAdd', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } + }, +}; diff --git a/Everything-Bot/src/events/messageReactionRemove.js b/Everything-Bot/src/events/messageReactionRemove.js new file mode 100644 index 0000000..58edaa9 --- /dev/null +++ b/Everything-Bot/src/events/messageReactionRemove.js @@ -0,0 +1,41 @@ +import { Events } from 'discord.js'; +import { + resolveReactionContext, + findMappingForReaction, + applyReactionRoleChange, +} from '../utilities/reactionRoleHandler.js'; +import logger from '../utilities/logger.js'; + +export default { + name: Events.MessageReactionRemove, + once: false, + + /** + * @param {import('discord.js').MessageReaction | import('discord.js').PartialMessageReaction} reaction + * @param {import('discord.js').User | import('discord.js').PartialUser} user + */ + async execute(reaction, user) { + try { + if (user.bot) { + return; + } + + const context = await resolveReactionContext(reaction, user); + if (!context) { + return; + } + + const mapping = findMappingForReaction(context.guild, context.message, context.reaction); + if (!mapping) { + return; + } + + await applyReactionRoleChange('remove', context.guild, context.user, mapping); + } catch (error) { + logger.error('Unhandled error in messageReactionRemove', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } + }, +}; diff --git a/Everything-Bot/src/events/ready.js b/Everything-Bot/src/events/ready.js new file mode 100644 index 0000000..5c49f2b --- /dev/null +++ b/Everything-Bot/src/events/ready.js @@ -0,0 +1,37 @@ +import { Events } from 'discord.js'; +import { reactionRoleService } from '../services/reactionRoleService.js'; +import { syncMissingReactions } from '../utilities/ensureBotReaction.js'; +import logger from '../utilities/logger.js'; + +export default { + name: Events.ClientReady, + once: true, + + /** + * @param {import('discord.js').Client} client + */ + async execute(client) { + logger.info('Successful Discord login', { + userTag: client.user.tag, + userId: client.user.id, + guildCount: client.guilds.cache.size, + }); + + try { + const mappings = reactionRoleService.listAll(); + if (mappings.length > 0) { + logger.info('Syncing missing reactions for stored mappings', { + mappingCount: mappings.length, + }); + const result = await syncMissingReactions(client, mappings); + logger.info('Reaction sync complete', result); + } + } catch (error) { + logger.error('Failed to sync missing reactions on startup', { + error: error instanceof Error ? error.message : String(error), + }); + } + + logger.info('Bot is ready and accepting events'); + }, +}; diff --git a/Everything-Bot/src/index.js b/Everything-Bot/src/index.js new file mode 100644 index 0000000..7c10845 --- /dev/null +++ b/Everything-Bot/src/index.js @@ -0,0 +1,135 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + Client, + Collection, + GatewayIntentBits, + Partials, +} from 'discord.js'; +import env from './config/environment.js'; +import { initDatabase, closeDatabase } from './database/database.js'; +import { loadCommands } from './utilities/loadCommands.js'; +import { loadEvents } from './utilities/loadEvents.js'; +import logger from './utilities/logger.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** @type {boolean} */ +let isShuttingDown = false; + +/** + * Creates and starts the Discord client with modular command and event loaders. + */ +async function main() { + logger.info('Bot startup initiated'); + + initDatabase(env.databasePath); + + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessageReactions, + ], + partials: [ + Partials.Message, + Partials.Channel, + Partials.Reaction, + Partials.User, + ], + }); + + /** @type {Collection} */ + client.commands = new Collection(); + + const commandsPath = path.join(__dirname, 'commands'); + const eventsPath = path.join(__dirname, 'events'); + + const commands = await loadCommands(commandsPath); + client.commands = commands; + + await loadEvents(client, eventsPath); + + registerProcessHandlers(client); + + try { + await client.login(env.token); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('Discord login failed', { error: message }); + + if (message.toLowerCase().includes('disallowed intents')) { + console.error( + '\nLogin failed: Used disallowed intents.\n' + + 'Enable "Server Members Intent" in the Discord Developer Portal:\n' + + ' https://discord.com/developers/applications → Your App → Bot → Privileged Gateway Intents\n' + + 'Then restart the bot with: npm start\n', + ); + } + + closeDatabase(); + process.exit(1); + } +} + +/** + * Registers graceful shutdown and process-level error handlers. + * @param {import('discord.js').Client} client + */ +function registerProcessHandlers(client) { + const shutdown = async (signal) => { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + logger.info('Shutdown signal received; stopping new work', { signal }); + + try { + client.destroy(); + logger.info('Discord client closed'); + } catch (error) { + logger.error('Error while closing Discord client', { + error: error instanceof Error ? error.message : String(error), + }); + } + + closeDatabase(); + logger.info('Bot has shut down'); + process.exit(0); + }; + + process.on('SIGINT', () => { + void shutdown('SIGINT'); + }); + + process.on('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + process.on('unhandledRejection', (reason) => { + logger.error('Unhandled promise rejection', { + error: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); + }); + + process.on('uncaughtException', (error) => { + logger.error('Uncaught exception', { + error: error.message, + stack: error.stack, + }); + + void shutdown('uncaughtException'); + }); +} + +main().catch((error) => { + logger.error('Fatal startup error', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + closeDatabase(); + process.exit(1); +}); diff --git a/Everything-Bot/src/services/reactionRoleService.js b/Everything-Bot/src/services/reactionRoleService.js new file mode 100644 index 0000000..0d0bd0c --- /dev/null +++ b/Everything-Bot/src/services/reactionRoleService.js @@ -0,0 +1,241 @@ +import { getDatabase } from '../database/database.js'; +import logger from '../utilities/logger.js'; + +/** + * @typedef {object} ReactionRoleRow + * @property {number} id + * @property {string} guild_id + * @property {string} channel_id + * @property {string} message_id + * @property {string} role_id + * @property {string} emoji_identifier + * @property {string} emoji_display + * @property {string} created_by + * @property {string} created_at + */ + +/** + * @typedef {object} CreateReactionRoleInput + * @property {string} guildId + * @property {string} channelId + * @property {string} messageId + * @property {string} roleId + * @property {string} emojiIdentifier + * @property {string} emojiDisplay + * @property {string} createdBy + */ + +/** + * Data-access and lookup helpers for reaction-role mappings. + */ +export const reactionRoleService = { + /** + * Inserts a new reaction-role mapping. + * @param {CreateReactionRoleInput} input + * @returns {ReactionRoleRow} + */ + create(input) { + const db = getDatabase(); + + try { + const result = db + .prepare( + `INSERT INTO reaction_roles ( + guild_id, channel_id, message_id, role_id, + emoji_identifier, emoji_display, created_by + ) VALUES ( + @guildId, @channelId, @messageId, @roleId, + @emojiIdentifier, @emojiDisplay, @createdBy + )`, + ) + .run({ + guildId: input.guildId, + channelId: input.channelId, + messageId: input.messageId, + roleId: input.roleId, + emojiIdentifier: input.emojiIdentifier, + emojiDisplay: input.emojiDisplay, + createdBy: input.createdBy, + }); + + const row = this.getById(Number(result.lastInsertRowid)); + if (!row) { + throw new Error('Failed to load reaction role after insert.'); + } + + logger.info('Reaction-role mapping created', { + id: row.id, + guildId: row.guild_id, + messageId: row.message_id, + roleId: row.role_id, + emoji: row.emoji_display, + }); + + return row; + } catch (error) { + if (isUniqueConstraintError(error)) { + const duplicateError = new Error('DUPLICATE_MAPPING'); + duplicateError.cause = error; + throw duplicateError; + } + + logger.error('Database error while creating reaction role', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, + + /** + * @param {number} id + * @returns {ReactionRoleRow | undefined} + */ + getById(id) { + return getDatabase() + .prepare('SELECT * FROM reaction_roles WHERE id = ?') + .get(id); + }, + + /** + * Finds a mapping by guild, message, and emoji identifier. + * @param {string} guildId + * @param {string} messageId + * @param {string} emojiIdentifier + * @returns {ReactionRoleRow | undefined} + */ + findByMessageAndEmoji(guildId, messageId, emojiIdentifier) { + return getDatabase() + .prepare( + `SELECT * FROM reaction_roles + WHERE guild_id = ? AND message_id = ? AND emoji_identifier = ?`, + ) + .get(guildId, messageId, emojiIdentifier); + }, + + /** + * Lists all mappings for a guild, newest first. + * @param {string} guildId + * @returns {ReactionRoleRow[]} + */ + listByGuild(guildId) { + return getDatabase() + .prepare( + `SELECT * FROM reaction_roles + WHERE guild_id = ? + ORDER BY created_at DESC, id DESC`, + ) + .all(guildId); + }, + + /** + * Lists every reaction-role mapping across all guilds. + * @returns {ReactionRoleRow[]} + */ + listAll() { + return getDatabase() + .prepare( + `SELECT * FROM reaction_roles + ORDER BY guild_id ASC, message_id ASC, id ASC`, + ) + .all(); + }, + + /** + * Lists all mappings for a specific message in a guild. + * @param {string} guildId + * @param {string} messageId + * @returns {ReactionRoleRow[]} + */ + listByMessage(guildId, messageId) { + return getDatabase() + .prepare( + `SELECT * FROM reaction_roles + WHERE guild_id = ? AND message_id = ? + ORDER BY id ASC`, + ) + .all(guildId, messageId); + }, + + /** + * Deletes a single mapping by guild, message, and emoji identifier. + * @param {string} guildId + * @param {string} messageId + * @param {string} emojiIdentifier + * @returns {ReactionRoleRow | undefined} The deleted row, if any + */ + remove(guildId, messageId, emojiIdentifier) { + const existing = this.findByMessageAndEmoji(guildId, messageId, emojiIdentifier); + if (!existing) { + return undefined; + } + + try { + getDatabase() + .prepare( + `DELETE FROM reaction_roles + WHERE guild_id = ? AND message_id = ? AND emoji_identifier = ?`, + ) + .run(guildId, messageId, emojiIdentifier); + + logger.info('Reaction-role mapping removed', { + id: existing.id, + guildId, + messageId, + emoji: existing.emoji_display, + }); + + return existing; + } catch (error) { + logger.error('Database error while removing reaction role', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, + + /** + * Deletes all mappings for a message in a guild. + * @param {string} guildId + * @param {string} messageId + * @returns {ReactionRoleRow[]} The deleted rows + */ + clearMessage(guildId, messageId) { + const existing = this.listByMessage(guildId, messageId); + if (existing.length === 0) { + return []; + } + + try { + getDatabase() + .prepare('DELETE FROM reaction_roles WHERE guild_id = ? AND message_id = ?') + .run(guildId, messageId); + + logger.info('Reaction-role mappings cleared for message', { + guildId, + messageId, + count: existing.length, + }); + + return existing; + } catch (error) { + logger.error('Database error while clearing reaction roles', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, +}; + +/** + * @param {unknown} error + * @returns {boolean} + */ +function isUniqueConstraintError(error) { + return ( + error instanceof Error && + typeof error.message === 'string' && + error.message.includes('UNIQUE constraint failed') + ); +} + +export default reactionRoleService; diff --git a/Everything-Bot/src/utilities/channels.js b/Everything-Bot/src/utilities/channels.js new file mode 100644 index 0000000..af838a6 --- /dev/null +++ b/Everything-Bot/src/utilities/channels.js @@ -0,0 +1,71 @@ +import logger from './logger.js'; + +/** + * Resolves the guild for an interaction. + * `interaction.guild` is often null when the guild is not cached yet. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @returns {Promise} + */ +export async function resolveInteractionGuild(interaction) { + if (!interaction.inGuild() || !interaction.guildId) { + return null; + } + + if (interaction.guild) { + return interaction.guild; + } + + try { + return await interaction.client.guilds.fetch(interaction.guildId); + } catch (error) { + logger.warn('Failed to fetch interaction guild', { + guildId: interaction.guildId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** + * Resolves the guild text-based channel for an interaction. + * `interaction.channel` is often null when the channel is not cached, + * so this falls back to fetching by `channelId`. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @returns {Promise} + */ +export async function resolveInteractionTextChannel(interaction) { + if (!interaction.inGuild() || !interaction.channelId) { + return null; + } + + const guild = await resolveInteractionGuild(interaction); + if (!guild) { + return null; + } + + /** @type {import('discord.js').Channel | null} */ + let channel = interaction.channel; + + if (!channel) { + try { + channel = + guild.channels.cache.get(interaction.channelId) ?? + (await guild.channels.fetch(interaction.channelId)); + } catch (error) { + logger.warn('Failed to fetch interaction channel', { + channelId: interaction.channelId, + guildId: interaction.guildId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + if (!channel || !channel.isTextBased() || channel.isDMBased()) { + return null; + } + + return /** @type {import('discord.js').GuildTextBasedChannel} */ (channel); +} diff --git a/Everything-Bot/src/utilities/emoji.js b/Everything-Bot/src/utilities/emoji.js new file mode 100644 index 0000000..8076de7 --- /dev/null +++ b/Everything-Bot/src/utilities/emoji.js @@ -0,0 +1,150 @@ +/** + * Emoji parsing and matching utilities. + * + * Storage convention: + * - Unicode emojis → store the emoji character as the identifier + * - Custom emojis → store the custom emoji ID as the identifier + * + * A separate display value preserves the original presentation for replies and embeds. + */ + +/** Matches Discord custom emoji markup: <:name:id> or */ +const CUSTOM_EMOJI_REGEX = /^<(a?):([a-zA-Z0-9_]+):(\d+)>$/; + +/** + * @typedef {object} ParsedEmoji + * @property {'unicode' | 'custom'} type + * @property {string} identifier - Value stored and matched in the database + * @property {string} display - Human-readable / Discord markup form + * @property {string} reactValue - Value passed to message.react() + * @property {boolean} [animated] + * @property {string} [name] + * @property {string} [id] + */ + +/** + * Parses a user-supplied emoji string into a consistent identifier and display form. + * + * @param {string} input + * @returns {ParsedEmoji | null} Null when the input cannot be interpreted as an emoji + */ +export function parseEmoji(input) { + if (typeof input !== 'string') { + return null; + } + + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + + const customMatch = CUSTOM_EMOJI_REGEX.exec(trimmed); + if (customMatch) { + const animated = customMatch[1] === 'a'; + const name = customMatch[2]; + const id = customMatch[3]; + const display = `<${animated ? 'a' : ''}:${name}:${id}>`; + + return { + type: 'custom', + identifier: id, + display, + reactValue: id, + animated, + name, + id, + }; + } + + // Reject leftover angle-bracket markup that failed the custom pattern + if (trimmed.startsWith('<') && trimmed.endsWith('>')) { + return null; + } + + // Reject bare numeric IDs — users should paste full custom emoji markup + if (/^\d{15,}$/.test(trimmed)) { + return null; + } + + if (!isLikelyUnicodeEmoji(trimmed)) { + return null; + } + + return { + type: 'unicode', + identifier: trimmed, + display: trimmed, + reactValue: trimmed, + }; +} + +/** + * Builds a consistent identifier from a Discord.js reaction emoji. + * + * @param {{ id: string | null, name: string | null }} emoji + * @returns {string | null} + */ +export function getEmojiIdentifier(emoji) { + if (!emoji) { + return null; + } + + if (emoji.id) { + return emoji.id; + } + + return emoji.name ?? null; +} + +/** + * Builds a display string from a Discord.js reaction emoji. + * + * @param {{ id: string | null, name: string | null, animated?: boolean }} emoji + * @returns {string} + */ +export function getEmojiDisplay(emoji) { + if (!emoji) { + return 'unknown'; + } + + if (emoji.id && emoji.name) { + return `<${emoji.animated ? 'a' : ''}:${emoji.name}:${emoji.id}>`; + } + + return emoji.name ?? 'unknown'; +} + +/** + * Checks whether a custom emoji is available to the client (shared / guild emoji cache). + * + * @param {import('discord.js').Client} client + * @param {string} emojiId + * @returns {boolean} + */ +export function isCustomEmojiAvailable(client, emojiId) { + return client.emojis.cache.has(emojiId); +} + +/** + * Lightweight heuristic for unicode emoji / emoji sequences. + * Rejects plain alphanumeric words while accepting common emoji forms. + * + * @param {string} value + * @returns {boolean} + */ +function isLikelyUnicodeEmoji(value) { + if (/\w{3,}/u.test(value) && !/\p{Extended_Pictographic}/u.test(value)) { + return false; + } + + // Extended pictographic, regional indicators, keycaps, ZWJ sequences, variation selectors + const emojiPattern = + /^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3)(?:\uFE0F|\u200D\p{Extended_Pictographic}|\p{Emoji_Modifier})*$/u; + + if (emojiPattern.test(value)) { + return true; + } + + // Fallback: short strings that are not plain Latin words + return value.length <= 16 && !/^[a-zA-Z0-9_]+$/.test(value); +} diff --git a/Everything-Bot/src/utilities/ensureBotReaction.js b/Everything-Bot/src/utilities/ensureBotReaction.js new file mode 100644 index 0000000..c64d046 --- /dev/null +++ b/Everything-Bot/src/utilities/ensureBotReaction.js @@ -0,0 +1,130 @@ +import { getEmojiIdentifier } from './emoji.js'; +import logger from './logger.js'; + +/** + * Ensures the bot has reacted to a message with the given emoji. + * If the reaction is missing (or the bot is not among the reactors), adds it. + * + * @param {import('discord.js').Message} message + * @param {string} emojiIdentifier Consistent stored identifier (unicode char or custom ID) + * @param {string} [reactValue] Value passed to message.react(); defaults to emojiIdentifier + * @returns {Promise} True when a reaction was added; false when already present + */ +export async function ensureBotReaction(message, emojiIdentifier, reactValue = emojiIdentifier) { + const existing = message.reactions.cache.find( + (reaction) => getEmojiIdentifier(reaction.emoji) === emojiIdentifier, + ); + + if (existing?.me) { + return false; + } + + await message.react(reactValue); + return true; +} + +/** + * Restores missing bot reactions for all stored reaction-role mappings. + * Failures for individual messages are logged and skipped so one bad channel + * cannot block the rest. + * + * @param {import('discord.js').Client} client + * @param {import('../services/reactionRoleService.js').ReactionRoleRow[]} mappings + * @returns {Promise<{ restored: number, skipped: number, failed: number }>} + */ +export async function syncMissingReactions(client, mappings) { + let restored = 0; + let skipped = 0; + let failed = 0; + + /** @type {Map} */ + const byMessage = new Map(); + + for (const mapping of mappings) { + const key = `${mapping.guild_id}:${mapping.channel_id}:${mapping.message_id}`; + const group = byMessage.get(key); + if (group) { + group.push(mapping); + } else { + byMessage.set(key, [mapping]); + } + } + + for (const [key, group] of byMessage) { + const { guild_id: guildId, channel_id: channelId, message_id: messageId } = group[0]; + + try { + const guild = + client.guilds.cache.get(guildId) ?? + (await client.guilds.fetch(guildId).catch(() => null)); + + if (!guild) { + skipped += group.length; + logger.warn('Skipping reaction sync; guild unavailable', { guildId, messageId }); + continue; + } + + const channel = + guild.channels.cache.get(channelId) ?? + (await guild.channels.fetch(channelId).catch(() => null)); + + if (!channel?.isTextBased()) { + skipped += group.length; + logger.warn('Skipping reaction sync; channel unavailable', { + guildId, + channelId, + messageId, + }); + continue; + } + + const message = await channel.messages.fetch(messageId).catch(() => null); + if (!message) { + skipped += group.length; + logger.warn('Skipping reaction sync; message not found', { + guildId, + channelId, + messageId, + }); + continue; + } + + for (const mapping of group) { + try { + const added = await ensureBotReaction( + message, + mapping.emoji_identifier, + mapping.emoji_identifier, + ); + + if (added) { + restored += 1; + logger.info('Restored missing reaction on mapped message', { + guildId, + messageId, + emoji: mapping.emoji_display, + }); + } else { + skipped += 1; + } + } catch (error) { + failed += 1; + logger.warn('Failed to restore reaction on mapped message', { + guildId, + messageId, + emoji: mapping.emoji_display, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } catch (error) { + failed += group.length; + logger.warn('Failed reaction sync group', { + key, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { restored, skipped, failed }; +} diff --git a/Everything-Bot/src/utilities/errors.js b/Everything-Bot/src/utilities/errors.js new file mode 100644 index 0000000..191753b --- /dev/null +++ b/Everything-Bot/src/utilities/errors.js @@ -0,0 +1,98 @@ +import { + DiscordAPIError, + MessageFlags, +} from 'discord.js'; +import logger from './logger.js'; + +/** + * Replies or follows up with an ephemeral error message. + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {string} content + */ +export async function replyEphemeralError(interaction, content) { + try { + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content, flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ content, flags: MessageFlags.Ephemeral }); + } + } catch (error) { + logger.error('Failed to send ephemeral error reply', { + error: error instanceof Error ? error.message : String(error), + commandName: interaction.commandName, + }); + } +} + +/** + * Maps known failure modes to user-facing messages. + * @param {unknown} error + * @returns {string} + */ +export function describeCommandError(error) { + if (error instanceof Error) { + switch (error.message) { + case 'DUPLICATE_MAPPING': + return 'A reaction role for that emoji already exists on this message.'; + case 'MESSAGE_NOT_FOUND': + return 'Message not found in this channel. Make sure the message ID is correct and the message is in the current channel.'; + case 'INVALID_EMOJI': + return 'Invalid emoji. Use a unicode emoji (🎮) or custom emoji markup such as `<:name:id>` or ``.'; + case 'CUSTOM_EMOJI_UNAVAILABLE': + return 'That custom emoji is unavailable to the bot. Use an emoji from a server the bot is in.'; + case 'ROLE_NOT_FOUND': + return 'That role could not be found in this server.'; + case 'MAPPING_NOT_FOUND': + return 'No reaction-role mapping was found for that message and emoji.'; + case 'NO_MAPPINGS_FOR_MESSAGE': + return 'No reaction-role mappings were found for that message.'; + default: + break; + } + + if (error.message.startsWith('PERMISSION:')) { + return error.message.slice('PERMISSION:'.length).trim(); + } + } + + if (error instanceof DiscordAPIError) { + logger.error('Discord API error', { + code: error.code, + message: error.message, + status: error.status, + }); + + if (error.code === 50013) { + return 'Missing bot permissions. I need Manage Roles, Add Reactions, View Channels, and Read Message History.'; + } + + if (error.code === 10008) { + return 'Message not found. It may have been deleted.'; + } + + if (error.code === 10014) { + return 'Unknown emoji. The emoji may be invalid or unavailable.'; + } + + return `Discord API error: ${error.message}`; + } + + logger.error('Unexpected command error', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + + return 'An unexpected error occurred. Please try again later.'; +} + +/** + * @param {unknown} error + * @returns {boolean} + */ +export function isDatabaseError(error) { + return ( + error instanceof Error && + (error.message.includes('SQLITE_') || + error.message.toLowerCase().includes('database')) + ); +} diff --git a/Everything-Bot/src/utilities/loadCommands.js b/Everything-Bot/src/utilities/loadCommands.js new file mode 100644 index 0000000..b62b642 --- /dev/null +++ b/Everything-Bot/src/utilities/loadCommands.js @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Collection } from 'discord.js'; +import logger from './logger.js'; + +/** + * @typedef {object} BotCommand + * @property {import('discord.js').SlashCommandBuilder | import('discord.js').SlashCommandOptionsOnlyBuilder | { toJSON: () => object, name: string }} data + * @property {(interaction: import('discord.js').ChatInputCommandInteraction) => Promise} execute + */ + +/** + * Recursively collects JavaScript files under a directory. + * @param {string} directory + * @returns {string[]} + */ +function collectJsFiles(directory) { + if (!fs.existsSync(directory)) { + return []; + } + + /** @type {string[]} */ + const files = []; + const entries = fs.readdirSync(directory, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...collectJsFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(fullPath); + } + } + + return files; +} + +/** + * Loads command modules from the commands directory tree into a Collection. + * Nested folders are supported so new feature groups can be added freely. + * + * @param {string} commandsPath Absolute path to the commands root + * @returns {Promise>} + */ +export async function loadCommands(commandsPath) { + /** @type {Collection} */ + const commands = new Collection(); + const files = collectJsFiles(commandsPath); + + for (const filePath of files) { + try { + const moduleUrl = pathToFileURL(filePath).href; + const imported = await import(moduleUrl); + const command = imported.default; + + if (!command?.data?.name || typeof command.execute !== 'function') { + logger.warn('Skipping invalid command module', { filePath }); + continue; + } + + if (commands.has(command.data.name)) { + logger.warn('Duplicate command name; later module wins', { + name: command.data.name, + filePath, + }); + } + + commands.set(command.data.name, command); + logger.info('Loaded command', { name: command.data.name, filePath }); + } catch (error) { + logger.error('Failed to load command module', { + filePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return commands; +} diff --git a/Everything-Bot/src/utilities/loadEvents.js b/Everything-Bot/src/utilities/loadEvents.js new file mode 100644 index 0000000..3e28c40 --- /dev/null +++ b/Everything-Bot/src/utilities/loadEvents.js @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import logger from './logger.js'; + +/** + * @typedef {object} BotEvent + * @property {string} name + * @property {boolean} [once] + * @property {(...args: unknown[]) => Promise | void} execute + */ + +/** + * Loads event modules from a directory and registers them on the client. + * + * @param {import('discord.js').Client} client + * @param {string} eventsPath Absolute path to the events directory + * @returns {Promise} Number of successfully registered events + */ +export async function loadEvents(client, eventsPath) { + if (!fs.existsSync(eventsPath)) { + logger.warn('Events directory not found', { eventsPath }); + return 0; + } + + const files = fs + .readdirSync(eventsPath) + .filter((file) => file.endsWith('.js')) + .map((file) => path.join(eventsPath, file)); + + let registered = 0; + + for (const filePath of files) { + try { + const moduleUrl = pathToFileURL(filePath).href; + const imported = await import(moduleUrl); + /** @type {BotEvent} */ + const event = imported.default; + + if (!event?.name || typeof event.execute !== 'function') { + logger.warn('Skipping invalid event module', { filePath }); + continue; + } + + const handler = (...args) => { + Promise.resolve(event.execute(...args)).catch((error) => { + logger.error('Unhandled error in event handler', { + event: event.name, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + }); + }; + + if (event.once) { + client.once(event.name, handler); + } else { + client.on(event.name, handler); + } + + registered += 1; + logger.info('Registered event', { + name: event.name, + once: Boolean(event.once), + filePath, + }); + } catch (error) { + logger.error('Failed to load event module', { + filePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return registered; +} diff --git a/Everything-Bot/src/utilities/logger.js b/Everything-Bot/src/utilities/logger.js new file mode 100644 index 0000000..edb5b9f --- /dev/null +++ b/Everything-Bot/src/utilities/logger.js @@ -0,0 +1,114 @@ +/** + * Simple structured logger that never prints secrets or full interaction payloads. + */ +const LEVELS = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +const currentLevel = LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LEVELS.info; + +/** + * @param {string} level + * @param {string} message + * @param {Record} [meta] + */ +function write(level, message, meta = {}) { + if ((LEVELS[level] ?? 100) < currentLevel) { + return; + } + + const entry = { + timestamp: new Date().toISOString(), + level, + message, + ...sanitizeMeta(meta), + }; + + const line = JSON.stringify(entry); + + if (level === 'error' || level === 'warn') { + console.error(line); + } else { + console.log(line); + } +} + +/** + * Removes sensitive keys from log metadata. + * @param {Record} meta + * @returns {Record} + */ +function sanitizeMeta(meta) { + const blocked = new Set([ + 'token', + 'discord_token', + 'authorization', + 'password', + 'secret', + 'DISCORD_TOKEN', + ]); + + /** @type {Record} */ + const clean = {}; + + for (const [key, value] of Object.entries(meta)) { + if (blocked.has(key) || blocked.has(key.toLowerCase())) { + continue; + } + + if (key === 'interaction' && value && typeof value === 'object') { + const interaction = /** @type {Record} */ (value); + clean.interactionId = interaction.id; + clean.commandName = interaction.commandName; + clean.guildId = interaction.guildId; + clean.userId = + interaction.user && typeof interaction.user === 'object' + ? /** @type {{ id?: string }} */ (interaction.user).id + : undefined; + continue; + } + + clean[key] = value; + } + + return clean; +} + +export const logger = { + /** + * @param {string} message + * @param {Record} [meta] + */ + debug(message, meta) { + write('debug', message, meta); + }, + + /** + * @param {string} message + * @param {Record} [meta] + */ + info(message, meta) { + write('info', message, meta); + }, + + /** + * @param {string} message + * @param {Record} [meta] + */ + warn(message, meta) { + write('warn', message, meta); + }, + + /** + * @param {string} message + * @param {Record} [meta] + */ + error(message, meta) { + write('error', message, meta); + }, +}; + +export default logger; diff --git a/Everything-Bot/src/utilities/permissions.js b/Everything-Bot/src/utilities/permissions.js new file mode 100644 index 0000000..18516c6 --- /dev/null +++ b/Everything-Bot/src/utilities/permissions.js @@ -0,0 +1,160 @@ +import { MessageFlags, PermissionFlagsBits } from 'discord.js'; + +/** + * @typedef {import('discord.js').Guild} Guild + * @typedef {import('discord.js').GuildMember} GuildMember + * @typedef {import('discord.js').Role} Role + * @typedef {import('discord.js').ChatInputCommandInteraction} ChatInputCommandInteraction + */ + +/** + * Returns true when the member has the Manage Roles permission. + * @param {GuildMember | null | undefined} member + * @returns {boolean} + */ +export function memberCanManageRoles(member) { + if (!member) { + return false; + } + + return member.permissions.has(PermissionFlagsBits.ManageRoles); +} + +/** + * Returns true when the bot has Manage Roles in the guild. + * @param {Guild} guild + * @returns {boolean} + */ +export function botCanManageRoles(guild) { + const me = guild.members.me; + if (!me) { + return false; + } + + return me.permissions.has(PermissionFlagsBits.ManageRoles); +} + +/** + * Validates whether the bot can assign or remove the given role. + * Checks managed status, hierarchy, and Manage Roles permission. + * + * @param {Guild} guild + * @param {Role} role + * @returns {{ ok: true } | { ok: false, reason: string }} + */ +export function canBotManageRole(guild, role) { + const me = guild.members.me; + + if (!me) { + return { + ok: false, + reason: 'Unable to resolve the bot member in this server.', + }; + } + + if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) { + return { + ok: false, + reason: 'I need the Manage Roles permission to work with this role.', + }; + } + + if (role.managed) { + return { + ok: false, + reason: 'That role is managed by a Discord integration and cannot be assigned.', + }; + } + + if (role.id === guild.id) { + return { + ok: false, + reason: 'The @everyone role cannot be used for reaction roles.', + }; + } + + const botHighest = me.roles.highest; + + if (role.position >= botHighest.position) { + return { + ok: false, + reason: + "That role is equal to or higher than my highest role. Move my role above it in Server Settings → Roles.", + }; + } + + return { ok: true }; +} + +/** + * Resolves the invoking member's permissions in the current guild context. + * `interaction.memberPermissions` is often null when guild/member data is not cached. + * + * @param {ChatInputCommandInteraction} interaction + * @returns {Promise | null>} + */ +export async function resolveMemberPermissions(interaction) { + if (!interaction.inGuild()) { + return null; + } + + if (interaction.memberPermissions) { + return interaction.memberPermissions; + } + + if (interaction.member && 'permissions' in interaction.member && interaction.member.permissions) { + return interaction.member.permissions; + } + + const guild = + interaction.guild ?? + (await interaction.client.guilds.fetch(interaction.guildId).catch(() => null)); + + if (!guild) { + return null; + } + + try { + const member = await guild.members.fetch(interaction.user.id); + return member.permissions; + } catch (_error) { + return null; + } +} + +/** + * Ensures the invoking user has Manage Roles; replies ephemerally on failure. + * @param {ChatInputCommandInteraction} interaction + * @returns {Promise} True when the user is allowed to continue. + */ +export async function requireManageRoles(interaction) { + if (!interaction.inGuild()) { + await interaction.reply({ + content: 'This command can only be used in a server.', + flags: MessageFlags.Ephemeral, + }); + return false; + } + + const permissions = await resolveMemberPermissions(interaction); + + if (!permissions) { + await interaction.reply({ + content: + 'Could not verify your permissions in this server. ' + + 'Make sure the bot is online and has been invited to this server.', + flags: MessageFlags.Ephemeral, + }); + return false; + } + + if (!permissions.has(PermissionFlagsBits.ManageRoles)) { + await interaction.reply({ + content: 'You need the Manage Roles permission to use this command.', + flags: MessageFlags.Ephemeral, + }); + return false; + } + + return true; +} diff --git a/Everything-Bot/src/utilities/reactionRoleHandler.js b/Everything-Bot/src/utilities/reactionRoleHandler.js new file mode 100644 index 0000000..a96866e --- /dev/null +++ b/Everything-Bot/src/utilities/reactionRoleHandler.js @@ -0,0 +1,176 @@ +import { canBotManageRole } from '../utilities/permissions.js'; +import { getEmojiIdentifier } from '../utilities/emoji.js'; +import { reactionRoleService } from '../services/reactionRoleService.js'; +import logger from '../utilities/logger.js'; + +/** + * Ensures a possibly-partial reaction, message, and user are fully available. + * Returns null when the object was deleted or cannot be resolved. + * + * @param {import('discord.js').MessageReaction | import('discord.js').PartialMessageReaction} reaction + * @param {import('discord.js').User | import('discord.js').PartialUser} user + * @returns {Promise<{ reaction: import('discord.js').MessageReaction, user: import('discord.js').User, message: import('discord.js').Message, guild: import('discord.js').Guild } | null>} + */ +export async function resolveReactionContext(reaction, user) { + try { + if (reaction.partial) { + await reaction.fetch(); + } + } catch (error) { + logger.warn('Failed to fetch partial reaction (message may be deleted)', { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + + try { + if (user.partial) { + await user.fetch(); + } + } catch (error) { + logger.warn('Failed to fetch partial user', { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + + let message = reaction.message; + + try { + if (message.partial) { + message = await message.fetch(); + } + } catch (error) { + logger.warn('Failed to fetch partial message (may be deleted)', { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + + const guild = message.guild; + if (!guild) { + return null; + } + + return { + reaction: /** @type {import('discord.js').MessageReaction} */ (reaction), + user: /** @type {import('discord.js').User} */ (user), + message: /** @type {import('discord.js').Message} */ (message), + guild, + }; +} + +/** + * Looks up a configured mapping for a reaction event. + * + * @param {import('discord.js').Guild} guild + * @param {import('discord.js').Message} message + * @param {import('discord.js').MessageReaction} reaction + * @returns {import('../services/reactionRoleService.js').ReactionRoleRow | undefined} + */ +export function findMappingForReaction(guild, message, reaction) { + const emojiIdentifier = getEmojiIdentifier(reaction.emoji); + if (!emojiIdentifier) { + return undefined; + } + + return reactionRoleService.findByMessageAndEmoji(guild.id, message.id, emojiIdentifier); +} + +/** + * Assigns or removes a role for a reaction-role mapping after safety checks. + * + * @param {'add' | 'remove'} action + * @param {import('discord.js').Guild} guild + * @param {import('discord.js').User} user + * @param {import('../services/reactionRoleService.js').ReactionRoleRow} mapping + * @returns {Promise} + */ +export async function applyReactionRoleChange(action, guild, user, mapping) { + if (user.bot) { + return; + } + + let member; + try { + member = await guild.members.fetch(user.id); + } catch (error) { + logger.warn('Member not found while processing reaction role', { + userId: user.id, + guildId: guild.id, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + let role; + try { + role = await guild.roles.fetch(mapping.role_id); + } catch (error) { + logger.warn('Role fetch failed while processing reaction role', { + roleId: mapping.role_id, + guildId: guild.id, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + if (!role) { + logger.warn('Configured role no longer exists', { + roleId: mapping.role_id, + guildId: guild.id, + mappingId: mapping.id, + }); + return; + } + + const permissionCheck = canBotManageRole(guild, role); + if (!permissionCheck.ok) { + logger.warn('Permission failure while processing reaction role', { + action, + reason: permissionCheck.reason, + roleId: role.id, + guildId: guild.id, + userId: user.id, + }); + return; + } + + try { + if (action === 'add') { + if (member.roles.cache.has(role.id)) { + return; + } + + await member.roles.add(role, 'Reaction role'); + logger.info('Role assigned via reaction', { + userId: user.id, + roleId: role.id, + guildId: guild.id, + messageId: mapping.message_id, + emoji: mapping.emoji_display, + }); + } else { + if (!member.roles.cache.has(role.id)) { + return; + } + + await member.roles.remove(role, 'Reaction role removed'); + logger.info('Role removed via reaction', { + userId: user.id, + roleId: role.id, + guildId: guild.id, + messageId: mapping.message_id, + emoji: mapping.emoji_display, + }); + } + } catch (error) { + logger.error('Discord API error while updating member roles', { + action, + userId: user.id, + roleId: role.id, + guildId: guild.id, + error: error instanceof Error ? error.message : String(error), + }); + } +}