Reaction roles (JavaScript)

Gives members a role when they react to a message with the matching emoji and takes it away when they remove the reaction.

Node.js 22

Environment variables you'll need

KeyWhat it is
DISCORD_TOKENYour bot's token from the Discord Developer Portal. Paste it on the Secrets tab; we store it encrypted.
REACTION_ROLESPairs of emoji=role id, separated by commas, e.g. 👍=123456789,🎮=987654321
ROLE_MESSAGE_IDThe message to watch. /reactionroles-setup posts one and prints its id

Create an account to use it

Files

README.md

# Reaction roles

A JavaScript bot built on discord.js. Your bot token is set on the Secrets tab as
DISCORD_TOKEN and reaches the bot as an environment variable.

## Commands

- `/reactionroles-setup` (needs Manage Roles) posts a message listing the pairs, adds the reactions and prints the message id. Put that id into ROLE_MESSAGE_ID on the Secrets tab and restart the bot.
- Custom emoji work by name or id, e.g. `party=123` or `<:party:111>=123`.
- The bot's highest role must be above every role it hands out.

## Setup

1. No privileged gateway intents are needed. Leave Message Content, Server Members
   and Presence off in the Discord Developer Portal.
2. Invite the bot with the `bot` and `applications.commands` scopes and these
   permissions: Manage Roles, Add Reactions, Send Messages, Read Message History.
3. Set these values on the Secrets tab. The ones marked optional can stay empty:

- `REACTION_ROLES`: Pairs of emoji=role id, separated by commas, e.g. 👍=123456789,🎮=987654321.
- `ROLE_MESSAGE_ID`: The message to watch. /reactionroles-setup posts one and prints its id.

4. Press Deploy. The console shows a line with "Logged in as" once the bot is online.

index.js

const {
  Client, Events, GatewayIntentBits, MessageFlags, Partials, PermissionFlagsBits, SlashCommandBuilder,
} = require('discord.js');

const CUSTOM_EMOJI = /^<a?:(\w+):(\d+)>$/;
const VARIATION_SELECTOR = /\uFE0F/g;

// Custom emoji written as <:name:id> become their id; unicode emoji lose U+FE0F.
function normalize(key) {
  const trimmed = key.trim();
  const match = trimmed.match(CUSTOM_EMOJI);
  return match ? match[2] : trimmed.replace(VARIATION_SELECTOR, '');
}

function parsePairs(raw) {
  const pairs = new Map();
  for (const item of raw.split(',')) {
    const at = item.lastIndexOf('=');
    if (at < 0) continue;
    const emoji = item.slice(0, at).trim();
    const roleId = item.slice(at + 1).trim();
    if (emoji && /^\d+$/.test(roleId)) pairs.set(normalize(emoji), { original: emoji, roleId });
    else console.log(`Skipping bad REACTION_ROLES entry: ${item}`);
  }
  return pairs;
}

const ROLE_MESSAGE_ID = (process.env.ROLE_MESSAGE_ID || '').trim();
const PAIRS = parsePairs(process.env.REACTION_ROLES || '');

// Match a reaction by custom emoji id, custom emoji name, or unicode text.
function roleFor(emoji) {
  for (const key of [emoji.id, emoji.name && normalize(emoji.name)]) {
    if (key && PAIRS.has(key)) return PAIRS.get(key).roleId;
  }
  return null;
}

const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessageReactions],
  // Partials let the bot see reactions on messages sent before it started.
  partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.User],
});

const commands = [
  new SlashCommandBuilder().setName('reactionroles-setup')
    .setDescription('Post the reaction roles message in this channel')
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles)
    .toJSON(),
];

async function updateRole(reaction, user, add) {
  if (!ROLE_MESSAGE_ID || reaction.message.id !== ROLE_MESSAGE_ID) return;
  const roleId = roleFor(reaction.emoji);
  const guild = reaction.message.guild;
  if (!roleId || !guild) return;
  const role = guild.roles.cache.get(roleId);
  if (!role) {
    console.log(`Role ${roleId} for ${reaction.emoji.name} does not exist in ${guild.name}`);
    return;
  }
  try {
    const member = await guild.members.fetch(user.id);
    if (member.user.bot) return;
    if (add) await member.roles.add(role, 'Reaction role');
    else await member.roles.remove(role, 'Reaction role');
  } catch (err) {
    console.error(`Could not update role ${role.name}: ${err.message}. Is my role above it?`);
  }
}

client.on(Events.MessageReactionAdd, (reaction, user) => updateRole(reaction, user, true));
client.on(Events.MessageReactionRemove, (reaction, user) => updateRole(reaction, user, false));

// Returns something message.react() accepts: a guild emoji, an id, or unicode text.
function resolveEmoji(guild, key, original) {
  if (/^\d+$/.test(key)) return guild.emojis.cache.get(key) || client.emojis.cache.get(key) || null;
  return guild.emojis.cache.find((e) => e.name === key) || original;
}

async function setup(interaction) {
  if (PAIRS.size === 0) {
    await interaction.reply({ content: 'REACTION_ROLES is empty. Set it in the Secrets tab first.', flags: MessageFlags.Ephemeral });
    return;
  }
  // posting and reacting can take longer than the 3 second reply window
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });
  const { guild } = interaction;
  const lines = ['React to get a role. Remove your reaction to lose it.'];
  const emojis = [];
  for (const [key, { original, roleId }] of PAIRS) {
    const emoji = resolveEmoji(guild, key, original);
    emojis.push([emoji, original]);
    const role = guild.roles.cache.get(roleId);
    lines.push(`${emoji || original} : ${role ? role.toString() : `missing role ${roleId}`}`);
  }
  const message = await interaction.channel.send({ content: lines.join('\n'), allowedMentions: { parse: [] } });
  for (const [emoji, original] of emojis) {
    try {
      if (!emoji) thr

package.json

{
  "name": "reaction-roles-js",
  "private": true,
  "main": "index.js",
  "engines": { "node": ">=22" },
  "dependencies": { "discord.js": "^14.0.0" }
}