Initial commit

This commit is contained in:
2026-06-27 11:32:25 -05:00
commit 865ab2007b
18 changed files with 2926 additions and 0 deletions
+16
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
data/
*.log
+237
View File
@@ -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.
+87
View File
@@ -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
```
+1035
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+22
View File
@@ -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()];
+18
View File
@@ -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"),
};
+47
View File
@@ -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 ?? "");
}
+5
View File
@@ -0,0 +1,5 @@
import crypto from "node:crypto";
export function shortId(bytes = 8): string {
return crypto.randomBytes(bytes).toString("base64url");
}
+619
View File
@@ -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);
});
+50
View File
@@ -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);
});
+21
View File
@@ -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
View File
@@ -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)}…`;
}
+139
View File
@@ -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
View File
@@ -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();
}
+61
View File
@@ -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[];
}
+16
View File
@@ -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"]
}