Initial commit
This commit is contained in:
@@ -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 -- <path>`.
|
||||
# Defaults to data/pingme.json when no path argument is provided.
|
||||
PINGME_DATA_FILE=data/pingme.json
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
@@ -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:<message_id>
|
||||
/pingme help
|
||||
```
|
||||
|
||||
Later optional commands:
|
||||
|
||||
```text
|
||||
/pingme edit message:<message_id>
|
||||
/pingme refresh message:<message_id>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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:<message id>
|
||||
/pingme help
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build and run compiled JavaScript:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
Generated
+1035
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { APIMessageComponentEmoji } from "discord.js";
|
||||
|
||||
const CUSTOM_EMOJI_RE = /^<(?<animated>a?):(?<name>[A-Za-z0-9_]+):(?<id>\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 ?? "");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function shortId(bytes = 8): string {
|
||||
return crypto.randomBytes(bytes).toString("base64url");
|
||||
}
|
||||
+619
@@ -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<string, WizardSession>();
|
||||
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<void> {
|
||||
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:<id>`" },
|
||||
),
|
||||
],
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReactionRoleButton(interaction: ButtonInteraction): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId("title")
|
||||
.setLabel("Title")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setMaxLength(256)
|
||||
.setValue(session.draft.embedTitle ?? ""),
|
||||
),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId("description")
|
||||
.setLabel("Description")
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setRequired(false)
|
||||
.setMaxLength(4000)
|
||||
.setValue(session.draft.embedDescription ?? ""),
|
||||
),
|
||||
new ActionRowBuilder<TextInputBuilder>().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<TextInputBuilder>().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<TextInputBuilder>().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId("label")
|
||||
.setLabel("Button label")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(true)
|
||||
.setMaxLength(80),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().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<WizardSession | undefined> {
|
||||
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<PermissionsBitField> | null): boolean {
|
||||
if (!permissions) return false;
|
||||
return permissions.has(PermissionFlagsBits.ManageRoles) || permissions.has(PermissionFlagsBits.Administrator);
|
||||
}
|
||||
|
||||
async function disablePublishedMessage(config: ReactionRoleConfig): Promise<void> {
|
||||
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<void> {
|
||||
await storage.init();
|
||||
await client.login(env.token);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { REST, Routes } from "discord.js";
|
||||
import { commands } from "./commands";
|
||||
import { env } from "./config";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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);
|
||||
});
|
||||
+328
@@ -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<any>[] } {
|
||||
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<any>[] = [];
|
||||
|
||||
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<ChannelSelectMenuBuilder>().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<ButtonBuilder>().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<ButtonBuilder>().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<ButtonBuilder>().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<RoleSelectMenuBuilder>().addComponents(
|
||||
new RoleSelectMenuBuilder()
|
||||
.setCustomId(wizardCustomId(session.id, "role"))
|
||||
.setPlaceholder("Select a role to add"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (session.draft.options.length > 0) {
|
||||
components.push(
|
||||
new ActionRowBuilder<StringSelectMenuBuilder>().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<ButtonBuilder>().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<any>[] } {
|
||||
return {
|
||||
embeds: [buildReactionRoleEmbed(config)],
|
||||
components: config.type === "buttons" ? buildButtonRows(config.options) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReactionRoleEmbed(config: Pick<ReactionRoleConfig, "embedTitle" | "embedDescription" | "embedColor" | "embedFooter" | "type" | "options">): 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<ButtonBuilder> {
|
||||
const page = session.currentPage;
|
||||
return new ActionRowBuilder<ButtonBuilder>().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<any>[] {
|
||||
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<any>[] = [];
|
||||
components.push(new ActionRowBuilder<ButtonBuilder>().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<ButtonBuilder>[] {
|
||||
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
const sorted = [...options].sort((a, b) => a.position - b.position);
|
||||
for (let i = 0; i < sorted.length; i += 5) {
|
||||
const row = new ActionRowBuilder<ButtonBuilder>();
|
||||
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)}…`;
|
||||
}
|
||||
@@ -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<RoleChangeResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<RoleChangeResult> {
|
||||
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<void> {
|
||||
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<Role[]> {
|
||||
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<GuildMember | undefined> {
|
||||
try {
|
||||
return await guild.members.fetch(userId);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRole(guild: Guild, roleId: string): Promise<Role | undefined> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
+189
@@ -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<void> {
|
||||
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<ReactionRoleConfig[]> {
|
||||
const result = await this.pool.query<ReactionRoleMessageRow>(
|
||||
"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<ReactionRoleConfig | undefined> {
|
||||
const result = await this.pool.query<ReactionRoleMessageRow>(
|
||||
"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<ReactionRoleConfig | undefined> {
|
||||
const result = await this.pool.query<ReactionRoleMessageRow>(
|
||||
"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<ReactionRoleConfig | undefined> {
|
||||
const result = await this.pool.query<ReactionRoleMessageRow>(
|
||||
"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<void> {
|
||||
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<ReactionRoleConfig | undefined> {
|
||||
const result = await this.pool.query<ReactionRoleMessageRow>(
|
||||
"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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user