Reaction roles (Python)

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

Python 3.13

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 Python bot built on discord.py. 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.

main.py

import os
import re

import discord
from discord import app_commands

CUSTOM_EMOJI = re.compile(r"^<a?:(\w+):(\d+)>$")


def normalize(key):
    """Custom emoji written as <:name:id> become their id; unicode loses FE0F."""
    key = key.strip()
    match = CUSTOM_EMOJI.match(key)
    if match:
        return match.group(2)
    return key.replace(chr(0xFE0F), "")


def parse_pairs(raw):
    pairs = {}
    for item in raw.split(","):
        if "=" not in item:
            continue
        emoji, role_id = item.rsplit("=", 1)
        role_id = role_id.strip()
        if emoji.strip() and role_id.isdigit():
            pairs[normalize(emoji)] = (emoji.strip(), int(role_id))
        else:
            print(f"Skipping bad REACTION_ROLES entry: {item!r}")
    return pairs


MESSAGE_VALUE = os.environ.get("ROLE_MESSAGE_ID", "").strip()
ROLE_MESSAGE_ID = int(MESSAGE_VALUE) if MESSAGE_VALUE.isdigit() else None
PAIRS = parse_pairs(os.environ.get("REACTION_ROLES", ""))


def role_for(emoji):
    """Match a reaction by custom emoji id, custom emoji name, or unicode text."""
    keys = [str(emoji.id)] if emoji.id else []
    keys.append(normalize(emoji.name or ""))
    for key in keys:
        if key in PAIRS:
            return PAIRS[key][1]
    return None


class RoleBot(discord.Client):
    def __init__(self):
        super().__init__(intents=discord.Intents.default())
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        await self.tree.sync()


bot = RoleBot()


async def update_role(payload, add):
    if payload.guild_id is None or payload.message_id != ROLE_MESSAGE_ID:
        return
    role_id = role_for(payload.emoji)
    if role_id is None:
        return
    guild = bot.get_guild(payload.guild_id)
    if guild is None:
        return
    role = guild.get_role(role_id)
    if role is None:
        print(f"Role {role_id} for {payload.emoji} does not exist in {guild.name}")
        return
    try:
        member = payload.member or guild.get_member(payload.user_id) \
            or await guild.fetch_member(payload.user_id)
        if member.bot:
            return
        if add:
            await member.add_roles(role, reason="Reaction role")
        else:
            await member.remove_roles(role, reason="Reaction role")
    except discord.NotFound:
        return
    except discord.HTTPException as exc:
        print(f"Could not update role {role.name}: {exc}. Is my role above it?")


@bot.event
async def on_raw_reaction_add(payload):
    await update_role(payload, True)


@bot.event
async def on_raw_reaction_remove(payload):
    await update_role(payload, False)


def resolve_emoji(guild, key, original):
    """Return something add_reaction accepts: a guild Emoji or a unicode string."""
    if key.isdigit():
        return bot.get_emoji(int(key))
    found = discord.utils.get(guild.emojis, name=key)
    return found or original


@bot.tree.command(name="reactionroles-setup",
                  description="Post the reaction roles message in this channel")
@app_commands.default_permissions(manage_roles=True)
@app_commands.guild_only()
async def setup(interaction: discord.Interaction):
    if not PAIRS:
        await interaction.response.send_message(
            "REACTION_ROLES is empty. Set it in the Secrets tab first.", ephemeral=True)
        return
    # posting and reacting can take longer than the 3 second reply window
    await interaction.response.defer(ephemeral=True)
    guild = interaction.guild
    lines = ["React to get a role. Remove your reaction to lose it."]
    emojis = []
    for key, (original, role_id) in PAIRS.items():
        emoji = resolve_emoji(guild, key, original)
        emojis.append(emoji)
        role = guild.get_role(role_id)
        label = role.mention if role else f"missing role {role_id}"
        lines.append(f"{emoji or original} : {label}")
    message = await interaction.channel.send(
        "\n".join(lines), allowed_mentions=discord.AllowedMentions

requirements.txt

discord.py