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), + }); + } +}