Reaction roles for a Discord bot, in discord.py and discord.js

React with an emoji, get a role; take the reaction off, lose it. Here is the whole bot for discord.py and for discord.js, the one permission problem everybody hits, and buttons, which do the same job with fewer surprises.

8 min readIntermediateUpdated Examples tested with discord.py 2.7.1 and discord.js 14.27

Each example below was run against a test copy of Discord, reactions and button presses included, to check that the role goes on and comes off as it should.

Before the code

  • Manage Roles. The bot needs this permission, and its own role must sit above every role it hands out in Server Settings, Roles. A bot cannot give a role that is level with or above its own, however many permissions it has; Discord answers 50013 Missing Permissions.
  • The IDs. Turn on Developer Mode (User Settings, Advanced). Then right-click the message people will react to and Copy Message ID, and right-click each role in Server Settings, Roles and Copy Role ID.
  • The reactions. React to the message with each emoji yourself, so people only have to click.

discord.py

Python
import os

import discord
from discord.ext import commands

# The message people react to. Turn on Developer Mode (User Settings, Advanced),
# then right-click the message and Copy Message ID.
ROLE_MESSAGE_ID = 123456789012345678
# Emoji to role ID. A custom emoji is written "<:name:id>".
EMOJI_TO_ROLE = {
    "🎮": 111111111111111111,  # Gamer
    "🎨": 222222222222222222,  # Artist
}

bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())

async def member_and_role(payload: discord.RawReactionActionEvent):
    if payload.message_id != ROLE_MESSAGE_ID or payload.guild_id is None:
        return None
    guild = bot.get_guild(payload.guild_id)
    role_id = EMOJI_TO_ROLE.get(str(payload.emoji))
    role = guild.get_role(role_id) if guild and role_id else None
    if role is None:
        return None
    member = payload.member or guild.get_member(payload.user_id)
    if member is None:
        try:
            member = await guild.fetch_member(payload.user_id)  # a removal carries no member
        except discord.NotFound:
            return None  # they have left the server
    if member.bot:
        return None
    return member, role

@bot.event
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
    found = await member_and_role(payload)
    if found:
        member, role = found
        await member.add_roles(role, reason="Reaction role")

@bot.event
async def on_raw_reaction_remove(payload: discord.RawReactionActionEvent):
    found = await member_and_role(payload)
    if found:
        member, role = found
        await member.remove_roles(role, reason="Reaction role")

bot.run(os.environ["BOT_TOKEN"])
  • The raw events. on_raw_reaction_add fires for every message. on_reaction_add only fires for messages the bot has in memory, which after a restart is none of them, so a reaction role built on it stops working the first time the bot restarts.
  • No privileged intent. Intents.default() includes reactions. An added reaction carries the member, and a removed one does not, which is why the code fetches the member then instead of asking for the Server Members intent.
  • The emoji. str(payload.emoji) is the emoji itself for a normal one and <:name:id> for a server's own (<a:name:id> if it is animated), so those are the keys to write.

discord.js

JavaScript
const { Client, Events, GatewayIntentBits, Partials } = require("discord.js");

// The message people react to (Developer Mode on, right-click it, Copy Message ID).
const ROLE_MESSAGE_ID = "123456789012345678";
// Emoji to role ID. A custom emoji is written "<:name:id>".
const EMOJI_TO_ROLE = {
  "🎮": "111111111111111111", // Gamer
  "🎨": "222222222222222222", // Artist
};

const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessageReactions],
  partials: [Partials.Message, Partials.Reaction, Partials.User],
});

async function memberAndRole(reaction, user) {
  if (reaction.message.id !== ROLE_MESSAGE_ID || user.bot) return null;
  const roleId = EMOJI_TO_ROLE[reaction.emoji.toString()];
  const guild = reaction.message.guild;
  if (!roleId || !guild) return null;
  const member = await guild.members.fetch(user.id).catch(() => null); // null if they left
  return member ? { member, roleId } : null;
}

client.on(Events.MessageReactionAdd, async (reaction, user) => {
  const found = await memberAndRole(reaction, user);
  if (found) await found.member.roles.add(found.roleId, "Reaction role");
});

client.on(Events.MessageReactionRemove, async (reaction, user) => {
  const found = await memberAndRole(reaction, user);
  if (found) await found.member.roles.remove(found.roleId, "Reaction role");
});

client.login(process.env.BOT_TOKEN);
  • The three partials. discord.js only fires an event for things it already has in memory. Partials.Message and Partials.Reaction make it fire for a message from before the bot started. Partials.User covers a reaction taken off by someone the bot has not seen since it started: without it, that removal is dropped and the role stays.
  • IDs are strings. Write them in quotes. As numbers they are too long for JavaScript, which quietly rounds the last digits, and the IDs no longer match.
  • reaction.emoji.toString() gives the same keys as in Python: the emoji itself, or <:name:id>.

Buttons instead

Buttons do the same job and avoid most of what can go wrong with reactions. There is no message ID to copy, a press tells the person what happened, nobody has to find the emoji, and a button only offers the roles you listed. One press adds the role and the next takes it away. Post the buttons with a slash command; in discord.py:

Python
import os

import discord
from discord import app_commands
from discord.ext import commands

ROLES = {  # button label to role ID
    "Gamer": 111111111111111111,
    "Artist": 222222222222222222,
}

class RoleButtons(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)
        for label, role_id in ROLES.items():
            button = discord.ui.Button(label=label, style=discord.ButtonStyle.secondary, custom_id=f"role:{role_id}")
            button.callback = self.toggle
            self.add_item(button)

    async def toggle(self, interaction: discord.Interaction):
        role_id = int(interaction.data["custom_id"].removeprefix("role:"))
        role = interaction.guild.get_role(role_id) if role_id in ROLES.values() else None
        if role is None:
            await interaction.response.send_message("That role is gone.", ephemeral=True)
        elif role in interaction.user.roles:
            await interaction.user.remove_roles(role, reason="Role button")
            await interaction.response.send_message(f"Removed {role.name}.", ephemeral=True)
        else:
            await interaction.user.add_roles(role, reason="Role button")
            await interaction.response.send_message(f"Added {role.name}.", ephemeral=True)

class MyBot(commands.Bot):
    async def setup_hook(self):
        self.add_view(RoleButtons())  # answers these buttons on any message, after a restart too
        await self.tree.sync()

bot = MyBot(command_prefix="!", intents=discord.Intents.default())

@bot.tree.command(description="Post the role buttons in this channel")
@app_commands.default_permissions(manage_roles=True)
@app_commands.guild_only()
async def rolebuttons(interaction: discord.Interaction):
    await interaction.channel.send("Pick your roles:", view=RoleButtons())
    await interaction.response.send_message("Posted.", ephemeral=True)

bot.run(os.environ["BOT_TOKEN"])

add_view in setup_hook is what keeps old buttons working after a restart: the view has no timeout, and every button has a fixed custom_id, so the bot recognises the buttons on a message it posted last week. In discord.js, handling the press by its custom ID in interactionCreate does the same:

JavaScript
const {
  ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, Events, GatewayIntentBits, MessageFlags,
} = require("discord.js");

const ROLES = { Gamer: "111111111111111111", Artist: "222222222222222222" }; // label to role ID

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.on(Events.InteractionCreate, async (interaction) => {
  if (interaction.isChatInputCommand() && interaction.commandName === "rolebuttons") {
    const row = new ActionRowBuilder().addComponents(
      Object.entries(ROLES).map(([label, id]) =>
        new ButtonBuilder().setCustomId(`role:${id}`).setLabel(label).setStyle(ButtonStyle.Secondary)),
    );
    await interaction.channel.send({ content: "Pick your roles:", components: [row] });
    await interaction.reply({ content: "Posted.", flags: MessageFlags.Ephemeral });
    return;
  }

  if (interaction.isButton() && interaction.customId.startsWith("role:")) {
    const roleId = interaction.customId.slice("role:".length);
    if (!Object.values(ROLES).includes(roleId) || !interaction.inGuild()) {
      await interaction.reply({ content: "That role is gone.", flags: MessageFlags.Ephemeral });
      return;
    }
    const roles = interaction.member.roles;
    if (roles.cache.has(roleId)) {
      await roles.remove(roleId, "Role button");
      await interaction.reply({ content: "Removed.", flags: MessageFlags.Ephemeral });
    } else {
      await roles.add(roleId, "Role button");
      await interaction.reply({ content: "Added.", flags: MessageFlags.Ephemeral });
    }
  }
});

client.login(process.env.BOT_TOKEN);

Register /rolebuttons like any other command, with .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) so only people who can manage roles see it (the slash commands guide has the deploy script). After changing the list of roles, post the buttons again. In discord.py, the buttons for a role you took out of the list stop being answered, so people see "This interaction failed".

Common problems

  • 403 Forbidden (error code: 50013): Missing Permissions when adding the role: the bot's role is below the role it is giving, or it lacks Manage Roles. Drag the bot's role higher.
  • Nothing happens: the message ID is wrong (it is the message's, not the channel's), the emoji key does not match, or the bot cannot see that channel. Print str(payload.emoji) or reaction.emoji.toString() to see what arrives.
  • The role goes on but never comes off: in discord.js, Partials.User is missing. In discord.py, the code uses on_reaction_remove instead of the raw event.
  • It worked until the bot restarted: the non-raw events in discord.py, or no partials in discord.js.
  • "This interaction failed" on a role button: the button's role is no longer in the list, or the bot was not running. Post the buttons again after changing the list.

Press / or Ctrl K on any page to search.