commit 865ab2007bbfebbf7ea1827730a59684ef86bf40 Author: Owen Qwen Date: Sat Jun 27 11:32:25 2026 -0500 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..76f9f36 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Discord bot token from the Discord Developer Portal +DISCORD_TOKEN= + +# Application/client ID from the Discord Developer Portal +DISCORD_CLIENT_ID= + +# Optional. If set, `npm run register` registers commands to one dev guild instantly. +# If omitted, commands are registered globally and may take time to appear. +DISCORD_GUILD_ID= + +# Postgres connection string used for persistent storage +DATABASE_URL=postgres://postgres:postgres@localhost:5432/pingme + +# Optional, only used by `npm run migrate:json-to-postgres -- `. +# Defaults to data/pingme.json when no path argument is provided. +PINGME_DATA_FILE=data/pingme.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d98640 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +data/ +*.log diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..648906d --- /dev/null +++ b/PLAN.md @@ -0,0 +1,237 @@ +# Ping Me Plan + +## Goal +Build **Ping Me**, a minimal Discord reaction-role bot that lets server admins create simple role assignment embeds using either: + +- reaction emojis, or +- message buttons with optional emojis. + +Admins should be guided through setup with an easy step-by-step wizard where each step is shown as an embed page. The same setup message is edited as the admin clicks **Back** and **Next**. + +## Assumptions +- The first version targets Discord only. +- The bot will use slash commands and Discord interactions. +- Only users with an appropriate permission, such as `Manage Roles` or `Administrator`, can create/edit reaction-role messages. +- The bot must have `Manage Roles`, `Read Message History`, `Add Reactions`, `Send Messages`, `Embed Links`, and interaction permissions. +- Reaction-role messages are configured per guild/server. +- Role assignment should fail gracefully if the selected role is above the bot's highest role. + +## Core Features + +### 1. Reaction-role creation wizard +Command: + +```text +/pingme create +``` + +The command starts an interactive wizard visible only to the admin, or optionally in the target channel if Discord limitations require it. + +Wizard pages: + +1. **Intro / Target Channel** + - Explain what will be created. + - Select the channel where the final reaction-role embed will be posted. + +2. **Embed Content** + - Set embed title. + - Set embed description. + - Optional color. + - Optional footer. + +3. **Selection Type** + - Choose `Buttons` or `Reactions`. + +4. **Selection Mode** + - Choose `Single role only` or `Multiple roles allowed`. + +5. **Add Role Options** + - Add one or more entries. + - Each entry contains: + - role + - label/text + - emoji, optional for buttons and required/practical for reactions + - For buttons, emoji can be omitted. + - For reactions, each option needs a unique emoji. + +6. **Preview** + - Show the final embed. + - Show configured buttons or reaction list. + +7. **Publish** + - Confirm and post the final message in the chosen channel. + - Save the message/configuration to storage. + - For reaction mode, add the configured reactions to the message. + +Each wizard page uses one edited setup message with navigation controls: + +- `Back` +- `Next` +- `Cancel` +- page-specific buttons/select menus/modals + +### 2. Button role assignment +When a user clicks a configured button: + +- Look up the reaction-role config by guild/channel/message/custom ID. +- Determine the selected role. +- If mode is `multiple`, toggle that role: + - if the user has it, remove it; + - otherwise add it. +- If mode is `single`, ensure the user only has the selected role from this message: + - remove other roles configured on the same message; + - add the selected role. +- Reply ephemerally with a short success/failure message. + +### 3. Reaction role assignment +When a user adds a configured reaction: + +- Look up the config by guild/channel/message/emoji. +- Ignore bot reactions. +- If mode is `multiple`, add the mapped role. +- If mode is `single`: + - remove other configured roles from the member; + - remove the user's other reactions on that message; + - add the selected role. + +When a user removes a configured reaction: + +- Remove the mapped role, unless this conflicts with single-select state handling. + +### 4. Management commands +Initial useful commands: + +```text +/pingme create +/pingme list +/pingme delete message: +/pingme help +``` + +Later optional commands: + +```text +/pingme edit message: +/pingme refresh message: +``` + +## Data Model + +Use a small persistent database. SQLite is enough for local/self-hosted use; Postgres can be added later if needed. + +### `reaction_role_messages` + +- `id` +- `guild_id` +- `channel_id` +- `message_id` +- `created_by_user_id` +- `type`: `buttons` or `reactions` +- `selection_mode`: `single` or `multiple` +- `embed_title` +- `embed_description` +- `embed_color` +- `embed_footer` +- `created_at` +- `updated_at` + +### `reaction_role_options` + +- `id` +- `message_config_id` +- `role_id` +- `label` +- `emoji` +- `button_custom_id`, nullable for reaction mode +- `position` + +### `wizard_sessions` + +Can be stored in memory for v1, with timeout cleanup. + +Fields: + +- `guild_id` +- `admin_user_id` +- `setup_message_id` +- `current_page` +- draft embed fields +- draft type/mode +- draft options +- `created_at` +- `expires_at` + +## Suggested Technical Stack + +- Node.js + TypeScript +- `discord.js` +- SQLite with either Prisma, Drizzle, or a lightweight query layer +- Environment variables for bot token/client ID/guild dev ID + +## Important Discord Constraints + +- The bot can only assign roles below its highest role. +- Discord messages can have at most 5 action rows and 5 buttons per row, so button mode should support up to 25 options per message. +- Reaction mode should enforce unique emojis per message. +- Custom emojis may require the bot to be in the emoji's source server or otherwise able to use them. +- Removing a user's reaction requires appropriate permissions and access to message history. +- Interactions must be acknowledged quickly; longer work should defer replies. + +## Implementation Milestones + +### Milestone 1: Project setup +- Initialize TypeScript project. +- Add Discord client login. +- Register slash commands. +- Add basic logging and environment config. + +### Milestone 2: Persistence +- Add database setup and migrations. +- Implement repository functions for reaction-role messages and options. + +### Milestone 3: Creation wizard +- Implement `/pingme create`. +- Implement paginated embed wizard state. +- Add Back/Next/Cancel controls. +- Add modals/select menus for embed content, roles, emojis, and labels. +- Add preview and publish step. + +### Milestone 4: Button roles +- Render final button-based reaction-role messages. +- Handle button interactions. +- Implement single-select and multi-select logic. + +### Milestone 5: Reaction roles +- Render final reaction-based role messages. +- Add configured reactions after publishing. +- Handle reaction add/remove events. +- Implement single-select reaction cleanup. + +### Milestone 6: Management and polish +- Add `/pingme list`. +- Add `/pingme delete`. +- Add permission checks and clear error messages. +- Add validation for role hierarchy, duplicate emojis, and limits. +- Add README setup instructions. + +## Testing Plan + +Manual test cases: + +- Create a button-based multi-select role message. +- Create a button-based single-select role message. +- Create a reaction-based multi-select role message. +- Create a reaction-based single-select role message. +- Verify role hierarchy failures show helpful messages. +- Verify non-admins cannot create/delete configs. +- Verify deleting config disables future role assignment. +- Verify bot restart preserves published reaction-role messages. + +## Future Enhancements + +- Edit existing reaction-role messages. +- Role removal policy options, e.g. buttons can be assign-only or toggle. +- Import/export configurations. +- Templates for common role menus. +- Web dashboard. +- Localization. diff --git a/README.md b/README.md new file mode 100644 index 0000000..79a7b01 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Ping Me + +Minimal Discord reaction-role bot with a guided embed setup wizard. + +## Features + +- `/pingme create` step-by-step setup wizard +- One editable wizard message with Back/Next/Cancel controls +- Button role menus with optional emoji +- Reaction role menus with emoji +- Single-select mode: selecting a new option removes the previous configured role +- Multi-select mode: users can hold multiple configured roles +- Persistent Postgres storage + +## Setup + +1. Install dependencies: + + ```bash + npm install + ``` + +2. Copy environment file: + + ```bash + cp .env.example .env + ``` + +3. Create a Postgres database and fill in: + + - `DISCORD_TOKEN` + - `DISCORD_CLIENT_ID` + - `DATABASE_URL` (for example, `postgres://postgres:postgres@localhost:5432/pingme`) + - optional `DISCORD_GUILD_ID` for instant dev command registration + + The bot creates the required Postgres table and indexes on startup. + +4. If you have existing `data/pingme.json` state, migrate it once: + + ```bash + npm run migrate:json-to-postgres + # or choose a file explicitly: + npm run migrate:json-to-postgres -- ./data/pingme.json + ``` + +5. Register slash commands: + + ```bash + npm run register + ``` + +6. Start the bot: + + ```bash + npm run dev + ``` + +## Required Bot Permissions + +Give the bot these permissions in servers where it is used: + +- Manage Roles +- Send Messages +- Embed Links +- Add Reactions +- Read Message History +- Use External Emojis, if using external/custom emoji + +The bot can only assign roles below its highest role. + +## Commands + +```text +/pingme create +/pingme list +/pingme delete message_id: +/pingme help +``` + +## Production + +Build and run compiled JavaScript: + +```bash +npm run build +npm start +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8b89b18 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1035 @@ +{ + "name": "ping-me", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ping-me", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "discord.js": "^14.16.3", + "dotenv": "^16.4.5", + "pg": "^8.22.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/pg": "^8.20.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.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/discord-api-types": { + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/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/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c59bd6b --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "ping-me", + "version": "0.1.0", + "description": "Minimal Discord reaction roles bot with guided embed setup.", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "start": "node dist/index.js", + "register": "tsx src/register-commands.ts", + "migrate:json-to-postgres": "tsx src/migrate-json-to-postgres.ts" + }, + "keywords": [ + "discord", + "reaction-roles", + "roles", + "bot" + ], + "license": "MIT", + "dependencies": { + "discord.js": "^14.16.3", + "dotenv": "^16.4.5", + "pg": "^8.22.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/pg": "^8.20.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..8b9b57b --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,22 @@ +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; + +export const pingMeCommand = new SlashCommandBuilder() + .setName("pingme") + .setDescription("Create and manage Ping Me reaction-role messages") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + .addSubcommand((subcommand) => subcommand.setName("create").setDescription("Create a new reaction-role message")) + .addSubcommand((subcommand) => subcommand.setName("list").setDescription("List reaction-role messages in this server")) + .addSubcommand((subcommand) => + subcommand + .setName("delete") + .setDescription("Delete a saved reaction-role configuration") + .addStringOption((option) => + option + .setName("message_id") + .setDescription("The Discord message ID for the reaction-role message") + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => subcommand.setName("help").setDescription("Show Ping Me help")); + +export const commands = [pingMeCommand.toJSON()]; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7a15956 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,18 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +export const env = { + token: required("DISCORD_TOKEN"), + clientId: required("DISCORD_CLIENT_ID"), + guildId: process.env.DISCORD_GUILD_ID, + databaseUrl: required("DATABASE_URL"), +}; diff --git a/src/emoji.ts b/src/emoji.ts new file mode 100644 index 0000000..e7e271a --- /dev/null +++ b/src/emoji.ts @@ -0,0 +1,47 @@ +import type { APIMessageComponentEmoji } from "discord.js"; + +const CUSTOM_EMOJI_RE = /^<(?a?):(?[A-Za-z0-9_]+):(?\d+)>$/; + +export function parseColor(input: string): number | undefined { + const trimmed = input.trim(); + if (!trimmed) return undefined; + const normalized = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; + if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return undefined; + return Number.parseInt(normalized, 16); +} + +export function formatColor(color?: number): string { + if (color === undefined) return "Not set"; + return `#${color.toString(16).padStart(6, "0").toUpperCase()}`; +} + +export function customEmojiId(input?: string): string | undefined { + if (!input) return undefined; + return input.trim().match(CUSTOM_EMOJI_RE)?.groups?.id; +} + +export function emojiForButton(input?: string): APIMessageComponentEmoji | string | undefined { + if (!input?.trim()) return undefined; + const emoji = input.trim(); + const match = emoji.match(CUSTOM_EMOJI_RE); + if (!match?.groups) return emoji; + return { + id: match.groups.id, + name: match.groups.name, + animated: match.groups.animated === "a", + }; +} + +export function reactionEmojiKey(name: string | null, id: string | null, animated = false): string { + if (id && name) return `<${animated ? "a" : ""}:${name}:${id}>`; + return name ?? ""; +} + +export function emojiMatches(storedEmoji: string | undefined, reactionName: string | null, reactionId: string | null): boolean { + if (!storedEmoji) return false; + const stored = storedEmoji.trim(); + const reactionKey = reactionEmojiKey(reactionName, reactionId); + if (stored === reactionKey) return true; + if (reactionId && customEmojiId(stored) === reactionId) return true; + return stored === (reactionName ?? ""); +} diff --git a/src/id.ts b/src/id.ts new file mode 100644 index 0000000..df97ae5 --- /dev/null +++ b/src/id.ts @@ -0,0 +1,5 @@ +import crypto from "node:crypto"; + +export function shortId(bytes = 8): string { + return crypto.randomBytes(bytes).toString("base64url"); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..81fbf5f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,619 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ButtonStyle, + ChannelSelectMenuInteraction, + Client, + EmbedBuilder, + GatewayIntentBits, + Interaction, + ModalBuilder, + ModalSubmitInteraction, + Partials, + PermissionFlagsBits, + PermissionsBitField, + RoleSelectMenuInteraction, + StringSelectMenuInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { env } from "./config"; +import { parseColor } from "./emoji"; +import { shortId } from "./id"; +import { buildPublishedMessage, buildWizardMessage, canAdvance, reactionRoleButtonCustomId, WIZARD_PAGE_COUNT, wizardCustomId } from "./render"; +import { applyButtonRoleSelection, applyReactionAdd, applyReactionRemove, isTextSendableGuildChannel } from "./role-service"; +import { PostgresStorage } from "./storage"; +import type { ReactionRoleConfig, ReactionRoleOption, RoleMessageType, SelectionMode, WizardSession } from "./types"; + +const SESSION_TTL_MS = 30 * 60 * 1000; +const sessions = new Map(); +const storage = new PostgresStorage(env.databaseUrl); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, + ], + partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.User], +}); + +client.once("ready", () => { + console.log(`Ping Me is online as ${client.user?.tag ?? "unknown bot"}.`); +}); + +client.on("interactionCreate", async (interaction) => { + try { + if (interaction.isChatInputCommand()) { + await handleSlashCommand(interaction); + return; + } + + if (interaction.isButton()) { + if (interaction.customId.startsWith("pm:rr:")) { + await handleReactionRoleButton(interaction); + return; + } + if (interaction.customId.startsWith("pm:w:")) { + await handleWizardButton(interaction); + return; + } + } + + if (interaction.isChannelSelectMenu() && interaction.customId.startsWith("pm:w:")) { + await handleWizardChannelSelect(interaction); + return; + } + + if (interaction.isRoleSelectMenu() && interaction.customId.startsWith("pm:w:")) { + await handleWizardRoleSelect(interaction); + return; + } + + if (interaction.isStringSelectMenu() && interaction.customId.startsWith("pm:w:")) { + await handleWizardStringSelect(interaction); + return; + } + + if (interaction.isModalSubmit() && interaction.customId.startsWith("pm:w:")) { + await handleWizardModal(interaction); + } + } catch (error) { + console.error(error); + if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { + await interaction.reply({ content: "Something went wrong. Check the bot logs for details.", ephemeral: true }).catch(() => undefined); + } + } +}); + +client.on("messageReactionAdd", async (reaction, user) => { + try { + const fetchedReaction = reaction.partial ? await reaction.fetch() : reaction; + const fetchedUser = user.partial ? await user.fetch() : user; + if (fetchedUser.bot) return; + + const message = fetchedReaction.message; + const guild = message.guild; + if (!guild) return; + + const config = await storage.findByMessage(guild.id, message.id); + if (!config || config.type !== "reactions") return; + + await applyReactionAdd(guild, fetchedUser, fetchedReaction, config); + } catch (error) { + console.error("Failed to handle reaction add", error); + } +}); + +client.on("messageReactionRemove", async (reaction, user) => { + try { + const fetchedReaction = reaction.partial ? await reaction.fetch() : reaction; + const fetchedUser = user.partial ? await user.fetch() : user; + if (fetchedUser.bot) return; + + const message = fetchedReaction.message; + const guild = message.guild; + if (!guild) return; + + const config = await storage.findByMessage(guild.id, message.id); + if (!config || config.type !== "reactions") return; + + await applyReactionRemove(guild, fetchedUser, fetchedReaction, config); + } catch (error) { + console.error("Failed to handle reaction remove", error); + } +}); + +async function handleSlashCommand(interaction: Interaction & { commandName: string }): Promise { + if (!interaction.isChatInputCommand() || interaction.commandName !== "pingme") return; + if (!interaction.inCachedGuild()) { + await interaction.reply({ content: "Ping Me only works inside a server.", ephemeral: true }); + return; + } + + if (!hasManageRolesPermission(interaction.memberPermissions)) { + await interaction.reply({ content: "You need Manage Roles to use Ping Me setup commands.", ephemeral: true }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + if (subcommand === "create") { + const session = createWizardSession(interaction.guildId, interaction.user.id); + sessions.set(session.id, session); + await interaction.reply({ ...buildWizardMessage(session), ephemeral: true }); + return; + } + + if (subcommand === "list") { + const configs = await storage.listByGuild(interaction.guildId); + const embed = new EmbedBuilder().setTitle("Ping Me Messages").setColor(0x5865f2); + if (!configs.length) { + embed.setDescription("No reaction-role messages have been created in this server yet."); + } else { + embed.setDescription( + configs + .slice(0, 15) + .map((config) => `• [${config.embedTitle || config.id}](https://discord.com/channels/${config.guildId}/${config.channelId}/${config.messageId}) — ${config.type}, ${config.selectionMode}`) + .join("\n"), + ); + } + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; + } + + if (subcommand === "delete") { + const messageId = interaction.options.getString("message_id", true); + const deleted = await storage.deleteByMessage(interaction.guildId, messageId); + if (!deleted) { + await interaction.reply({ content: "I could not find a saved Ping Me config for that message ID.", ephemeral: true }); + return; + } + + await disablePublishedMessage(deleted).catch(() => undefined); + await interaction.reply({ content: `Deleted the Ping Me config for message ${messageId}.`, ephemeral: true }); + return; + } + + if (subcommand === "help") { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setTitle("Ping Me Help") + .setColor(0x5865f2) + .setDescription("Use `/pingme create` to start a step-by-step reaction-role setup wizard.") + .addFields( + { name: "Create", value: "`/pingme create`" }, + { name: "List", value: "`/pingme list`" }, + { name: "Delete", value: "`/pingme delete message_id:`" }, + ), + ], + ephemeral: true, + }); + } +} + +async function handleReactionRoleButton(interaction: ButtonInteraction): Promise { + if (!interaction.inCachedGuild()) { + await interaction.reply({ content: "This role button only works inside a server.", ephemeral: true }); + return; + } + + const parts = interaction.customId.split(":"); + const optionId = parts[3]; + const config = await storage.findByButtonCustomId(interaction.customId); + if (!config || config.guildId !== interaction.guildId || !optionId) { + await interaction.reply({ content: "This role button is no longer configured.", ephemeral: true }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + const result = await applyButtonRoleSelection(interaction.guild, interaction.user.id, config, optionId); + await interaction.editReply(result.message); +} + +async function handleWizardButton(interaction: ButtonInteraction): Promise { + const parsed = getWizardAction(interaction.customId); + const session = await getSessionForInteraction(interaction, parsed.sessionId); + if (!session) return; + + if (parsed.action === "noop") { + await interaction.deferUpdate(); + return; + } + + if (parsed.action === "cancel") { + sessions.delete(session.id); + await interaction.update({ embeds: [new EmbedBuilder().setTitle("Ping Me setup canceled").setColor(0xed4245)], components: [] }); + return; + } + + if (parsed.action === "back") { + session.currentPage = Math.max(0, session.currentPage - 1); + await interaction.update(buildWizardMessage(session)); + return; + } + + if (parsed.action === "next") { + if (!canAdvance(session)) { + await interaction.reply({ content: "Please complete this step before continuing.", ephemeral: true }); + return; + } + session.currentPage = Math.min(WIZARD_PAGE_COUNT - 1, session.currentPage + 1); + await interaction.update(buildWizardMessage(session)); + return; + } + + if (parsed.action === "editEmbed") { + await interaction.showModal(buildEmbedModal(session)); + return; + } + + if (parsed.action === "type:buttons" || parsed.action === "type:reactions") { + session.draft.type = parsed.action.split(":")[1] as RoleMessageType; + // Reaction options require emoji, so keep options but force the admin to fill missing emoji before advancing. + await interaction.update(buildWizardMessage(session)); + return; + } + + if (parsed.action === "mode:single" || parsed.action === "mode:multiple") { + session.draft.selectionMode = parsed.action.split(":")[1] as SelectionMode; + await interaction.update(buildWizardMessage(session)); + return; + } + + if (parsed.action === "publish") { + await publishSession(interaction, session); + } +} + +async function handleWizardChannelSelect(interaction: ChannelSelectMenuInteraction): Promise { + const parsed = getWizardAction(interaction.customId); + const session = await getSessionForInteraction(interaction, parsed.sessionId); + if (!session) return; + session.draft.targetChannelId = interaction.values[0]; + await interaction.update(buildWizardMessage(session)); +} + +async function handleWizardRoleSelect(interaction: RoleSelectMenuInteraction): Promise { + const parsed = getWizardAction(interaction.customId); + const session = await getSessionForInteraction(interaction, parsed.sessionId); + if (!session) return; + const roleId = interaction.values[0]; + session.pendingRoleId = roleId; + await interaction.showModal(buildOptionModal(session)); +} + +async function handleWizardStringSelect(interaction: StringSelectMenuInteraction): Promise { + const parsed = getWizardAction(interaction.customId); + const session = await getSessionForInteraction(interaction, parsed.sessionId); + if (!session) return; + + if (parsed.action === "removeOption") { + const optionId = interaction.values[0]; + session.draft.options = session.draft.options.filter((option) => option.id !== optionId); + await interaction.update(buildWizardMessage(session)); + } +} + +async function handleWizardModal(interaction: ModalSubmitInteraction): Promise { + const parsed = getWizardAction(interaction.customId); + const session = await getSessionForInteraction(interaction, parsed.sessionId); + if (!session) return; + + if (parsed.action === "modal:embed") { + const title = interaction.fields.getTextInputValue("title").trim(); + const description = interaction.fields.getTextInputValue("description").trim(); + const colorRaw = interaction.fields.getTextInputValue("color").trim(); + const footer = interaction.fields.getTextInputValue("footer").trim(); + const color = parseColor(colorRaw); + + if (colorRaw && color === undefined) { + await interaction.reply({ content: "Color must be a 6-digit hex value like `#5865F2`.", ephemeral: true }); + return; + } + + session.draft.embedTitle = title; + session.draft.embedDescription = description; + session.draft.embedColor = color; + session.draft.embedFooter = footer || undefined; + await interaction.deferUpdate(); + await interaction.editReply(buildWizardMessage(session)); + return; + } + + if (parsed.action === "modal:option") { + const roleId = session.pendingRoleId; + if (!roleId) { + await interaction.reply({ content: "No role was selected. Please select a role again.", ephemeral: true }); + return; + } + + if (!session.draft.type) { + await interaction.reply({ content: "Choose buttons or reactions before adding options.", ephemeral: true }); + return; + } + + const emoji = interaction.fields.getTextInputValue("emoji").trim() || undefined; + const label = + session.draft.type === "reactions" + ? ((interaction.inCachedGuild() + ? interaction.guild.roles.cache.get(roleId) ?? (await interaction.guild.roles.fetch(roleId).catch(() => null)) + : null)?.name ?? roleId) + : interaction.fields.getTextInputValue("label").trim(); + + if (session.draft.type === "buttons" && !label) { + await interaction.reply({ content: "Button label is required.", ephemeral: true }); + return; + } + + if (session.draft.type === "reactions" && !emoji) { + await interaction.reply({ content: "Emoji is required for reaction options.", ephemeral: true }); + return; + } + + if (emoji && session.draft.options.some((option) => option.emoji === emoji)) { + await interaction.reply({ content: "That emoji is already used by another option in this message.", ephemeral: true }); + return; + } + + if (session.draft.options.length >= 25) { + await interaction.reply({ content: "A message can have at most 25 options.", ephemeral: true }); + return; + } + + session.draft.options.push({ id: shortId(5), roleId, label, emoji }); + session.pendingRoleId = undefined; + await interaction.deferUpdate(); + await interaction.editReply(buildWizardMessage(session)); + } +} + +async function publishSession(interaction: ButtonInteraction, session: WizardSession): Promise { + if (!interaction.inCachedGuild()) { + await interaction.reply({ content: "This setup session is not connected to a server anymore.", ephemeral: true }); + return; + } + + if (!validateFullSession(session)) { + await interaction.reply({ content: "Please complete all setup steps before publishing.", ephemeral: true }); + return; + } + + await interaction.deferUpdate(); + + const channel = await interaction.guild.channels.fetch(session.draft.targetChannelId!).catch(() => null); + if (!isTextSendableGuildChannel(channel)) { + await interaction.editReply({ content: "I cannot post in that channel. Pick another channel and try again.", embeds: [], components: [] }); + return; + } + + for (const option of session.draft.options) { + const role = interaction.guild.roles.cache.get(option.roleId) ?? (await interaction.guild.roles.fetch(option.roleId).catch(() => null)); + if (!role) { + await interaction.editReply({ content: `The role for option \`${option.label}\` no longer exists. Remove it and try again.`, embeds: [], components: [] }); + return; + } + if (role.managed || !role.editable) { + await interaction.editReply({ content: `I cannot manage ${role}. Move my bot role above it, then try again.`, embeds: [], components: [] }); + return; + } + } + + const now = new Date().toISOString(); + const configId = shortId(8); + const options: ReactionRoleOption[] = session.draft.options.map((option, index) => ({ + id: option.id, + roleId: option.roleId, + label: option.label, + emoji: option.emoji, + position: index, + buttonCustomId: session.draft.type === "buttons" ? reactionRoleButtonCustomId(configId, option.id) : undefined, + })); + + const config: ReactionRoleConfig = { + id: configId, + guildId: interaction.guildId, + channelId: channel.id, + messageId: "pending", + createdByUserId: session.adminUserId, + type: session.draft.type!, + selectionMode: session.draft.selectionMode!, + embedTitle: session.draft.embedTitle, + embedDescription: session.draft.embedDescription, + embedColor: session.draft.embedColor, + embedFooter: session.draft.embedFooter, + options, + createdAt: now, + updatedAt: now, + }; + + const message = await channel.send(buildPublishedMessage(config) as any); + config.messageId = message.id; + await storage.upsert(config); + + if (config.type === "reactions") { + for (const option of config.options) { + if (!option.emoji) continue; + await message.react(option.emoji).catch((error) => { + console.error(`Failed to add reaction ${option.emoji}`, error); + }); + } + } + + sessions.delete(session.id); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setTitle("Ping Me message published") + .setColor(0x57f287) + .setDescription(`Created the reaction-role message in <#${channel.id}>.\nMessage ID: \`${message.id}\``), + ], + components: [], + }); +} + +function createWizardSession(guildId: string, adminUserId: string): WizardSession { + const now = Date.now(); + return { + id: shortId(8), + guildId, + adminUserId, + currentPage: 0, + draft: { + embedTitle: "Pick your roles", + embedDescription: "Select an option below to update your roles.", + options: [], + }, + createdAt: now, + expiresAt: now + SESSION_TTL_MS, + }; +} + +function buildEmbedModal(session: WizardSession): ModalBuilder { + return new ModalBuilder() + .setCustomId(wizardCustomId(session.id, "modal:embed")) + .setTitle("Embed Content") + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("title") + .setLabel("Title") + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setMaxLength(256) + .setValue(session.draft.embedTitle ?? ""), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("description") + .setLabel("Description") + .setStyle(TextInputStyle.Paragraph) + .setRequired(false) + .setMaxLength(4000) + .setValue(session.draft.embedDescription ?? ""), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("color") + .setLabel("Color hex, optional") + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setMaxLength(7) + .setValue(session.draft.embedColor === undefined ? "" : `#${session.draft.embedColor.toString(16).padStart(6, "0")}`), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("footer") + .setLabel("Footer, optional") + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setMaxLength(2048) + .setValue(session.draft.embedFooter ?? ""), + ), + ); +} + +function buildOptionModal(session: WizardSession): ModalBuilder { + const modal = new ModalBuilder() + .setCustomId(wizardCustomId(session.id, "modal:option")) + .setTitle(session.draft.type === "reactions" ? "Add Reaction Role" : "Add Button Role"); + + if (session.draft.type !== "reactions") { + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("label") + .setLabel("Button label") + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(80), + ), + ); + } + + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("emoji") + .setLabel(session.draft.type === "reactions" ? "Reaction emoji" : "Button emoji, optional") + .setStyle(TextInputStyle.Short) + .setRequired(session.draft.type === "reactions") + .setMaxLength(100), + ), + ); + + return modal; +} + +async function getSessionForInteraction(interaction: Interaction, sessionId: string): Promise { + if (!interaction.isRepliable()) return undefined; + const session = sessions.get(sessionId); + if (!session || Date.now() > session.expiresAt) { + sessions.delete(sessionId); + await interaction.reply({ content: "That setup session has expired. Start again with `/pingme create`.", ephemeral: true }).catch(() => undefined); + return undefined; + } + + if (!interaction.inCachedGuild() || interaction.guildId !== session.guildId) { + await interaction.reply({ content: "That setup session belongs to another server.", ephemeral: true }).catch(() => undefined); + return undefined; + } + + if (interaction.user.id !== session.adminUserId) { + await interaction.reply({ content: "Only the admin who started this setup session can use these controls.", ephemeral: true }).catch(() => undefined); + return undefined; + } + + session.expiresAt = Date.now() + SESSION_TTL_MS; + return session; +} + +function getWizardAction(customId: string): { sessionId: string; action: string } { + const [, , sessionId, ...actionParts] = customId.split(":"); + return { sessionId, action: actionParts.join(":") }; +} + +function validateFullSession(session: WizardSession): boolean { + const draft = session.draft; + return Boolean( + draft.targetChannelId && + draft.type && + draft.selectionMode && + (draft.embedTitle || draft.embedDescription) && + draft.options.length > 0 && + (draft.type !== "reactions" || draft.options.every((option) => option.emoji)), + ); +} + +function hasManageRolesPermission(permissions: Readonly | null): boolean { + if (!permissions) return false; + return permissions.has(PermissionFlagsBits.ManageRoles) || permissions.has(PermissionFlagsBits.Administrator); +} + +async function disablePublishedMessage(config: ReactionRoleConfig): Promise { + const guild = await client.guilds.fetch(config.guildId).catch(() => null); + if (!guild) return; + const channel = await guild.channels.fetch(config.channelId).catch(() => null); + if (!isTextSendableGuildChannel(channel)) return; + const message = await channel.messages.fetch(config.messageId).catch(() => null); + if (!message) return; + await message.edit({ components: [] }); +} + +setInterval(() => { + const now = Date.now(); + for (const [id, session] of sessions.entries()) { + if (now > session.expiresAt) sessions.delete(id); + } +}, 60_000).unref(); + +async function main(): Promise { + await storage.init(); + await client.login(env.token); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/migrate-json-to-postgres.ts b/src/migrate-json-to-postgres.ts new file mode 100644 index 0000000..c9a0273 --- /dev/null +++ b/src/migrate-json-to-postgres.ts @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import path from "node:path"; +import dotenv from "dotenv"; +import { PostgresStorage } from "./storage"; +import type { ReactionRoleConfig, StoredData } from "./types"; + +dotenv.config(); + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +async function main(): Promise { + const databaseUrl = required("DATABASE_URL"); + const filePath = path.resolve(process.argv[2] ?? process.env.PINGME_DATA_FILE ?? "data/pingme.json"); + + if (!fs.existsSync(filePath)) { + throw new Error(`Legacy JSON data file not found: ${filePath}`); + } + + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as StoredData; + const configs = Array.isArray(parsed.reactionRoleMessages) ? parsed.reactionRoleMessages : []; + + const storage = new PostgresStorage(databaseUrl); + try { + await storage.init(); + for (const config of configs) { + validateConfig(config); + await storage.upsert(config, false); + } + console.log(`Migrated ${configs.length} reaction-role config(s) from ${filePath} to Postgres.`); + } finally { + await storage.close(); + } +} + +function validateConfig(config: ReactionRoleConfig): void { + if (!config.id || !config.guildId || !config.channelId || !config.messageId) { + throw new Error(`Invalid reaction-role config in JSON file: ${JSON.stringify(config)}`); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/register-commands.ts b/src/register-commands.ts new file mode 100644 index 0000000..ea4b236 --- /dev/null +++ b/src/register-commands.ts @@ -0,0 +1,21 @@ +import { REST, Routes } from "discord.js"; +import { commands } from "./commands"; +import { env } from "./config"; + +async function main(): Promise { + const rest = new REST({ version: "10" }).setToken(env.token); + + if (env.guildId) { + await rest.put(Routes.applicationGuildCommands(env.clientId, env.guildId), { body: commands }); + console.log(`Registered ${commands.length} command(s) to guild ${env.guildId}.`); + return; + } + + await rest.put(Routes.applicationCommands(env.clientId), { body: commands }); + console.log(`Registered ${commands.length} global command(s). Global commands may take time to update.`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..cff5de3 --- /dev/null +++ b/src/render.ts @@ -0,0 +1,328 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelSelectMenuBuilder, + ChannelType, + EmbedBuilder, + RoleSelectMenuBuilder, + StringSelectMenuBuilder, +} from "discord.js"; +import { emojiForButton, formatColor } from "./emoji"; +import type { DraftOption, ReactionRoleConfig, ReactionRoleOption, WizardSession } from "./types"; + +export const WIZARD_PAGE_COUNT = 7; +export const WIZARD_PAGES = [ + "Target Channel", + "Embed Content", + "Selection Type", + "Selection Mode", + "Role Options", + "Preview", + "Publish", +] as const; + +export function wizardCustomId(sessionId: string, action: string): string { + return `pm:w:${sessionId}:${action}`; +} + +export function reactionRoleButtonCustomId(configId: string, optionId: string): string { + return `pm:rr:${configId}:${optionId}`; +} + +export function buildWizardMessage(session: WizardSession): { embeds: EmbedBuilder[]; components: ActionRowBuilder[] } { + const page = session.currentPage; + const embed = new EmbedBuilder() + .setTitle(`Ping Me Setup — ${WIZARD_PAGES[page]}`) + .setColor(0x5865f2) + .setFooter({ text: `Page ${page + 1} of ${WIZARD_PAGE_COUNT} • Session expires automatically` }); + + const components: ActionRowBuilder[] = []; + + if (page === 0) { + embed.setDescription( + [ + "Choose the channel where the finished reaction-role embed should be posted.", + "", + `Current channel: ${session.draft.targetChannelId ? `<#${session.draft.targetChannelId}>` : "Not selected"}`, + ].join("\n"), + ); + components.push( + new ActionRowBuilder().addComponents( + new ChannelSelectMenuBuilder() + .setCustomId(wizardCustomId(session.id, "channel")) + .setPlaceholder("Select the target channel") + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement), + ), + ); + } + + if (page === 1) { + embed.setDescription("Set the embed text that users will see.").addFields( + { name: "Title", value: session.draft.embedTitle || "Not set", inline: false }, + { name: "Description", value: truncate(session.draft.embedDescription || "Not set", 1024), inline: false }, + { name: "Color", value: formatColor(session.draft.embedColor), inline: true }, + { name: "Footer", value: session.draft.embedFooter || "Not set", inline: true }, + ); + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(wizardCustomId(session.id, "editEmbed")) + .setLabel("Edit Embed Content") + .setStyle(ButtonStyle.Primary), + ), + ); + } + + if (page === 2) { + embed.setDescription("Choose how users will select roles.").addFields({ + name: "Current type", + value: session.draft.type === "buttons" ? "Buttons" : session.draft.type === "reactions" ? "Reactions" : "Not selected", + }); + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(wizardCustomId(session.id, "type:buttons")) + .setLabel("Buttons") + .setStyle(session.draft.type === "buttons" ? ButtonStyle.Success : ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(wizardCustomId(session.id, "type:reactions")) + .setLabel("Reactions") + .setStyle(session.draft.type === "reactions" ? ButtonStyle.Success : ButtonStyle.Secondary), + ), + ); + } + + if (page === 3) { + embed.setDescription("Choose whether users can select one role or many roles from this message.").addFields({ + name: "Current mode", + value: + session.draft.selectionMode === "single" + ? "Single role only" + : session.draft.selectionMode === "multiple" + ? "Multiple roles allowed" + : "Not selected", + }); + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(wizardCustomId(session.id, "mode:single")) + .setLabel("Single role only") + .setStyle(session.draft.selectionMode === "single" ? ButtonStyle.Success : ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(wizardCustomId(session.id, "mode:multiple")) + .setLabel("Multiple roles allowed") + .setStyle(session.draft.selectionMode === "multiple" ? ButtonStyle.Success : ButtonStyle.Secondary), + ), + ); + } + + if (page === 4) { + const optionInstructions = + session.draft.type === "reactions" + ? "Select a role below, then enter the reaction emoji. Reactions do not have labels." + : "Select a role below, then fill in the button label and optional emoji."; + embed + .setDescription([ + "Add the role options users can pick from.", + optionInstructions, + ].join("\n")) + .addFields({ name: `Options (${session.draft.options.length}/25)`, value: formatDraftOptions(session.draft.options, session.draft.type) }); + + if (session.draft.options.length < 25) { + components.push( + new ActionRowBuilder().addComponents( + new RoleSelectMenuBuilder() + .setCustomId(wizardCustomId(session.id, "role")) + .setPlaceholder("Select a role to add"), + ), + ); + } + + if (session.draft.options.length > 0) { + components.push( + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(wizardCustomId(session.id, "removeOption")) + .setPlaceholder("Remove an option") + .addOptions( + session.draft.options.slice(0, 25).map((option) => { + const selectOption: any = { + label: truncate(session.draft.type === "reactions" ? `${option.emoji ?? ""} ${option.label}`.trim() : option.label, 80), + description: `Role: ${option.roleId}`, + value: option.id, + default: false, + }; + const emoji = emojiForButton(option.emoji); + if (emoji) selectOption.emoji = emoji; + return selectOption; + }), + ), + ), + ); + } + } + + if (page === 5) { + embed + .setDescription("Preview the message. If anything looks wrong, go back and change it.") + .addFields( + { name: "Target", value: session.draft.targetChannelId ? `<#${session.draft.targetChannelId}>` : "Not selected", inline: true }, + { name: "Type", value: session.draft.type ?? "Not selected", inline: true }, + { name: "Mode", value: session.draft.selectionMode ?? "Not selected", inline: true }, + { name: "Final title", value: session.draft.embedTitle || "No title", inline: false }, + { name: "Final description", value: truncate(session.draft.embedDescription || "No description", 1024), inline: false }, + { name: "Final options", value: formatDraftOptions(session.draft.options, session.draft.type), inline: false }, + ); + components.push(...buildPreviewComponents(session)); + } + + if (page === 6) { + embed + .setDescription("Ready to publish the reaction-role message?") + .addFields( + { name: "Target", value: session.draft.targetChannelId ? `<#${session.draft.targetChannelId}>` : "Not selected", inline: true }, + { name: "Options", value: String(session.draft.options.length), inline: true }, + ); + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(wizardCustomId(session.id, "publish")) + .setLabel("Publish") + .setStyle(ButtonStyle.Success), + ), + ); + } + + components.push(buildNavigationRow(session)); + return { embeds: [embed], components }; +} + +export function buildPublishedMessage(config: ReactionRoleConfig): { embeds: EmbedBuilder[]; components: ActionRowBuilder[] } { + return { + embeds: [buildReactionRoleEmbed(config)], + components: config.type === "buttons" ? buildButtonRows(config.options) : [], + }; +} + +export function buildReactionRoleEmbed(config: Pick): EmbedBuilder { + const embed = new EmbedBuilder().setTitle(config.embedTitle || "Pick your roles").setDescription(config.embedDescription || "Select an option below."); + if (config.embedColor !== undefined) embed.setColor(config.embedColor); + if (config.embedFooter) embed.setFooter({ text: config.embedFooter }); + + const optionLines = config.options + .sort((a, b) => a.position - b.position) + .map((option) => + config.type === "reactions" + ? `${option.emoji ?? ""} — <@&${option.roleId}>` + : `${option.emoji ? `${option.emoji} ` : ""}${option.label} — <@&${option.roleId}>`, + ); + + if (optionLines.length) { + embed.addFields({ name: config.type === "reactions" ? "React for roles" : "Button roles", value: truncate(optionLines.join("\n"), 1024) }); + } + + return embed; +} + +function buildNavigationRow(session: WizardSession): ActionRowBuilder { + const page = session.currentPage; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(wizardCustomId(session.id, "back")) + .setLabel("Back") + .setStyle(ButtonStyle.Secondary) + .setDisabled(page === 0), + new ButtonBuilder() + .setCustomId(wizardCustomId(session.id, "next")) + .setLabel("Next") + .setStyle(ButtonStyle.Primary) + .setDisabled(page >= WIZARD_PAGE_COUNT - 1 || !canAdvance(session)), + new ButtonBuilder() + .setCustomId(wizardCustomId(session.id, "cancel")) + .setLabel("Cancel") + .setStyle(ButtonStyle.Danger), + ); +} + +function buildPreviewComponents(session: WizardSession): ActionRowBuilder[] { + const pseudoOptions: ReactionRoleOption[] = session.draft.options.map((option, index) => ({ + ...option, + position: index, + buttonCustomId: `preview-${option.id}`, + })); + + const previewEmbed = buildReactionRoleEmbed({ + type: session.draft.type ?? "buttons", + embedTitle: session.draft.embedTitle, + embedDescription: session.draft.embedDescription, + embedColor: session.draft.embedColor, + embedFooter: session.draft.embedFooter, + options: pseudoOptions, + }); + + const components: ActionRowBuilder[] = []; + components.push(new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(wizardCustomId(session.id, "noop")).setLabel("Preview below").setStyle(ButtonStyle.Secondary).setDisabled(true))); + // The actual publish preview is represented in the setup embed fields to keep the wizard in one message. + if (previewEmbed.data.fields?.length) { + // no-op: building validates the preview shape for callers and future edit support. + } + return components; +} + +function buildButtonRows(options: ReactionRoleOption[]): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + const sorted = [...options].sort((a, b) => a.position - b.position); + for (let i = 0; i < sorted.length; i += 5) { + const row = new ActionRowBuilder(); + for (const option of sorted.slice(i, i + 5)) { + const button = new ButtonBuilder() + .setCustomId(option.buttonCustomId ?? `missing-${option.id}`) + .setLabel(truncate(option.label, 80)) + .setStyle(ButtonStyle.Secondary); + const emoji = emojiForButton(option.emoji); + if (emoji) button.setEmoji(emoji as any); + row.addComponents(button); + } + rows.push(row); + } + return rows; +} + +function formatDraftOptions(options: DraftOption[], type?: "buttons" | "reactions"): string { + if (!options.length) return "No options yet."; + return truncate( + options + .map((option, index) => + type === "reactions" + ? `${index + 1}. ${option.emoji ?? ""} — <@&${option.roleId}>` + : `${index + 1}. ${option.emoji ? `${option.emoji} ` : ""}${option.label} — <@&${option.roleId}>`, + ) + .join("\n"), + 1024, + ); +} + +export function canAdvance(session: WizardSession): boolean { + const draft = session.draft; + switch (session.currentPage) { + case 0: + return Boolean(draft.targetChannelId); + case 1: + return Boolean(draft.embedTitle || draft.embedDescription); + case 2: + return Boolean(draft.type); + case 3: + return Boolean(draft.selectionMode); + case 4: + return draft.options.length > 0 && (draft.type !== "reactions" || draft.options.every((option) => Boolean(option.emoji))); + case 5: + return true; + default: + return false; + } +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max - 1)}…`; +} diff --git a/src/role-service.ts b/src/role-service.ts new file mode 100644 index 0000000..c796080 --- /dev/null +++ b/src/role-service.ts @@ -0,0 +1,139 @@ +import type { Guild, GuildBasedChannel, GuildMember, Message, MessageReaction, NewsChannel, Role, TextChannel, User } from "discord.js"; +import { ChannelType } from "discord.js"; +import { emojiMatches } from "./emoji"; +import type { ReactionRoleConfig, ReactionRoleOption } from "./types"; + +export interface RoleChangeResult { + ok: boolean; + message: string; +} + +export async function applyButtonRoleSelection(guild: Guild, userId: string, config: ReactionRoleConfig, optionId: string): Promise { + const option = config.options.find((candidate) => candidate.id === optionId); + if (!option) return { ok: false, message: "That role option no longer exists." }; + return applyRoleSelection(guild, userId, config, option, "button"); +} + +export async function applyReactionAdd(guild: Guild, user: User, reaction: MessageReaction, config: ReactionRoleConfig): Promise { + const option = config.options.find((candidate) => + emojiMatches(candidate.emoji, reaction.emoji.name, reaction.emoji.id), + ); + if (!option) return; + + await applyRoleSelection(guild, user.id, config, option, "reaction-add"); + + if (config.selectionMode === "single") { + await removeOtherUserReactions(reaction.message as Message, user.id, config, option); + } +} + +export async function applyReactionRemove(guild: Guild, user: User, reaction: MessageReaction, config: ReactionRoleConfig): Promise { + const option = config.options.find((candidate) => + emojiMatches(candidate.emoji, reaction.emoji.name, reaction.emoji.id), + ); + if (!option) return; + + const member = await fetchMember(guild, user.id); + if (!member) return; + + const role = await fetchRole(guild, option.roleId); + if (!role || !canManageRole(role)) return; + if (member.roles.cache.has(role.id)) { + await member.roles.remove(role, "Ping Me reaction role removed"); + } +} + +async function applyRoleSelection( + guild: Guild, + userId: string, + config: ReactionRoleConfig, + selectedOption: ReactionRoleOption, + source: "button" | "reaction-add", +): Promise { + const member = await fetchMember(guild, userId); + if (!member) return { ok: false, message: "I could not find your server member record." }; + + const selectedRole = await fetchRole(guild, selectedOption.roleId); + if (!selectedRole) return { ok: false, message: "That role no longer exists." }; + if (!canManageRole(selectedRole)) { + return { ok: false, message: `I cannot manage ${selectedRole}. Move my bot role above it and try again.` }; + } + + if (config.selectionMode === "multiple") { + if (source === "button" && member.roles.cache.has(selectedRole.id)) { + await member.roles.remove(selectedRole, "Ping Me button role toggled off"); + return { ok: true, message: `Removed ${selectedRole.name}.` }; + } + + if (!member.roles.cache.has(selectedRole.id)) { + await member.roles.add(selectedRole, "Ping Me role selected"); + } + return { ok: true, message: `Added ${selectedRole.name}.` }; + } + + if (source === "button" && member.roles.cache.has(selectedRole.id)) { + await member.roles.remove(selectedRole, "Ping Me single-select button role toggled off"); + return { ok: true, message: `Removed ${selectedRole.name}.` }; + } + + const removableRoles = await manageableConfiguredRoles(guild, config, selectedRole.id); + const rolesToRemove = removableRoles.filter((role) => member.roles.cache.has(role.id)); + if (rolesToRemove.length) { + await member.roles.remove(rolesToRemove, "Ping Me single-select role cleanup"); + } + + if (!member.roles.cache.has(selectedRole.id)) { + await member.roles.add(selectedRole, "Ping Me single-select role selected"); + } + + return { ok: true, message: `Selected ${selectedRole.name}.` }; +} + +async function removeOtherUserReactions(message: Message, userId: string, config: ReactionRoleConfig, selectedOption: ReactionRoleOption): Promise { + for (const option of config.options) { + if (option.id === selectedOption.id) continue; + const reaction = message.reactions.cache.find((candidate) => + emojiMatches(option.emoji, candidate.emoji.name, candidate.emoji.id), + ); + if (!reaction) continue; + try { + await reaction.users.remove(userId); + } catch { + // Missing permissions or old uncached reactions should not break role assignment. + } + } +} + +async function manageableConfiguredRoles(guild: Guild, config: ReactionRoleConfig, exceptRoleId: string): Promise { + const roles: Role[] = []; + const uniqueRoleIds = new Set(config.options.map((option) => option.roleId).filter((roleId) => roleId !== exceptRoleId)); + for (const roleId of uniqueRoleIds) { + const role = await fetchRole(guild, roleId); + if (role && canManageRole(role)) roles.push(role); + } + return roles; +} + +async function fetchMember(guild: Guild, userId: string): Promise { + try { + return await guild.members.fetch(userId); + } catch { + return undefined; + } +} + +async function fetchRole(guild: Guild, roleId: string): Promise { + return guild.roles.cache.get(roleId) ?? (await guild.roles.fetch(roleId).catch(() => null)) ?? undefined; +} + +function canManageRole(role: Role): boolean { + return !role.managed && role.editable; +} + +export function isTextSendableGuildChannel(channel: GuildBasedChannel | null): channel is TextChannel | NewsChannel { + return Boolean( + channel && + (channel.type === ChannelType.GuildText || channel.type === ChannelType.GuildAnnouncement) && + "send" in channel, + ); +} diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..ec37991 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,189 @@ +import { Pool, type QueryResultRow } from "pg"; +import type { ReactionRoleConfig, ReactionRoleOption, RoleMessageType, SelectionMode } from "./types"; + +interface ReactionRoleMessageRow extends QueryResultRow { + id: string; + guild_id: string; + channel_id: string; + message_id: string; + created_by_user_id: string; + type: RoleMessageType; + selection_mode: SelectionMode; + embed_title: string; + embed_description: string; + embed_color: number | null; + embed_footer: string | null; + options: ReactionRoleOption[] | string; + created_at: Date | string; + updated_at: Date | string; +} + +export class PostgresStorage { + private readonly pool: Pool; + + constructor(connectionString: string) { + this.pool = new Pool({ connectionString }); + } + + async init(): Promise { + await this.pool.query(` + CREATE TABLE IF NOT EXISTS reaction_role_messages ( + id text PRIMARY KEY, + guild_id text NOT NULL, + channel_id text NOT NULL, + message_id text NOT NULL, + created_by_user_id text NOT NULL, + type text NOT NULL CHECK (type IN ('buttons', 'reactions')), + selection_mode text NOT NULL CHECK (selection_mode IN ('single', 'multiple')), + embed_title text NOT NULL, + embed_description text NOT NULL, + embed_color integer, + embed_footer text, + options jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + UNIQUE (guild_id, message_id) + ) + `); + + await this.pool.query(` + CREATE INDEX IF NOT EXISTS reaction_role_messages_guild_created_idx + ON reaction_role_messages (guild_id, created_at DESC) + `); + + await this.pool.query(` + CREATE INDEX IF NOT EXISTS reaction_role_messages_options_gin_idx + ON reaction_role_messages USING gin (options) + `); + } + + async listByGuild(guildId: string): Promise { + const result = await this.pool.query( + "SELECT * FROM reaction_role_messages WHERE guild_id = $1 ORDER BY created_at DESC", + [guildId], + ); + return result.rows.map(rowToConfig); + } + + async findById(id: string): Promise { + const result = await this.pool.query( + "SELECT * FROM reaction_role_messages WHERE id = $1 LIMIT 1", + [id], + ); + return result.rows[0] ? rowToConfig(result.rows[0]) : undefined; + } + + async findByMessage(guildId: string, messageId: string): Promise { + const result = await this.pool.query( + "SELECT * FROM reaction_role_messages WHERE guild_id = $1 AND message_id = $2 LIMIT 1", + [guildId, messageId], + ); + return result.rows[0] ? rowToConfig(result.rows[0]) : undefined; + } + + async findByButtonCustomId(customId: string): Promise { + const result = await this.pool.query( + "SELECT * FROM reaction_role_messages WHERE options @> $1::jsonb LIMIT 1", + [JSON.stringify([{ buttonCustomId: customId }])], + ); + return result.rows[0] ? rowToConfig(result.rows[0]) : undefined; + } + + async upsert(config: ReactionRoleConfig, touchUpdatedAt = true): Promise { + const next = { ...config, updatedAt: touchUpdatedAt ? new Date().toISOString() : config.updatedAt }; + await this.pool.query( + ` + INSERT INTO reaction_role_messages ( + id, + guild_id, + channel_id, + message_id, + created_by_user_id, + type, + selection_mode, + embed_title, + embed_description, + embed_color, + embed_footer, + options, + created_at, + updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14) + ON CONFLICT (id) DO UPDATE SET + guild_id = EXCLUDED.guild_id, + channel_id = EXCLUDED.channel_id, + message_id = EXCLUDED.message_id, + created_by_user_id = EXCLUDED.created_by_user_id, + type = EXCLUDED.type, + selection_mode = EXCLUDED.selection_mode, + embed_title = EXCLUDED.embed_title, + embed_description = EXCLUDED.embed_description, + embed_color = EXCLUDED.embed_color, + embed_footer = EXCLUDED.embed_footer, + options = EXCLUDED.options, + updated_at = EXCLUDED.updated_at + `, + configValues(next), + ); + } + + async deleteByMessage(guildId: string, messageId: string): Promise { + const result = await this.pool.query( + "DELETE FROM reaction_role_messages WHERE guild_id = $1 AND message_id = $2 RETURNING *", + [guildId, messageId], + ); + return result.rows[0] ? rowToConfig(result.rows[0]) : undefined; + } + + async close(): Promise { + await this.pool.end(); + } + +} + +function configValues(config: ReactionRoleConfig): unknown[] { + return [ + config.id, + config.guildId, + config.channelId, + config.messageId, + config.createdByUserId, + config.type, + config.selectionMode, + config.embedTitle, + config.embedDescription, + config.embedColor ?? null, + config.embedFooter ?? null, + JSON.stringify(config.options), + config.createdAt, + config.updatedAt, + ]; +} + +function rowToConfig(row: ReactionRoleMessageRow): ReactionRoleConfig { + return { + id: row.id, + guildId: row.guild_id, + channelId: row.channel_id, + messageId: row.message_id, + createdByUserId: row.created_by_user_id, + type: row.type, + selectionMode: row.selection_mode, + embedTitle: row.embed_title, + embedDescription: row.embed_description, + embedColor: row.embed_color ?? undefined, + embedFooter: row.embed_footer ?? undefined, + options: parseOptions(row.options), + createdAt: toIsoString(row.created_at), + updatedAt: toIsoString(row.updated_at), + }; +} + +function parseOptions(options: ReactionRoleOption[] | string): ReactionRoleOption[] { + if (Array.isArray(options)) return options; + return JSON.parse(options) as ReactionRoleOption[]; +} + +function toIsoString(value: Date | string): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a285918 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,61 @@ +export type RoleMessageType = "buttons" | "reactions"; +export type SelectionMode = "single" | "multiple"; + +export interface ReactionRoleOption { + id: string; + roleId: string; + label: string; + emoji?: string; + buttonCustomId?: string; + position: number; +} + +export interface ReactionRoleConfig { + id: string; + guildId: string; + channelId: string; + messageId: string; + createdByUserId: string; + type: RoleMessageType; + selectionMode: SelectionMode; + embedTitle: string; + embedDescription: string; + embedColor?: number; + embedFooter?: string; + options: ReactionRoleOption[]; + createdAt: string; + updatedAt: string; +} + +export interface DraftOption { + id: string; + roleId: string; + label: string; + emoji?: string; +} + +export interface WizardDraft { + targetChannelId?: string; + type?: RoleMessageType; + selectionMode?: SelectionMode; + embedTitle: string; + embedDescription: string; + embedColor?: number; + embedFooter?: string; + options: DraftOption[]; +} + +export interface WizardSession { + id: string; + guildId: string; + adminUserId: string; + currentPage: number; + draft: WizardDraft; + pendingRoleId?: string; + createdAt: number; + expiresAt: number; +} + +export interface StoredData { + reactionRoleMessages: ReactionRoleConfig[]; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7e2b6c3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +}