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
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_addfires for every message.on_reaction_addonly 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
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.MessageandPartials.Reactionmake it fire for a message from before the bot started.Partials.Usercovers 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:
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:
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 Permissionswhen 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)orreaction.emoji.toString()to see what arrives. - The role goes on but never comes off: in discord.js,
Partials.Useris missing. In discord.py, the code useson_reaction_removeinstead 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.
Did this page help?
Thanks for the feedback.