Discord Role Bot: Buttons So Everyone Picks Their Own Roles (Python and JavaScript)
A role bot posts a message with buttons and everyone on your Discord server clicks the ones they want: the game they play, whether they want event pings, their region… Here it is in full in discord.py and discord.js, about 70 lines: a /role-panel command to post the message and the buttons that give and take each role. No database and none of the hassle of reaction roles.
Rather not set it up yourself? Discord bot hosting from €1.50 a month, no contract and a full refund within 72 hours. Or create a free Discord bot, no card needed.
1. Create the bot on Discord
Create the application and get the token
At discord.com/developers/applications, “New Application”; in the “Bot” tab, “Reset Token” and copy the token. It's the bot's password: don't share it or push it to GitHub. If it's your first bot, the step-by-step process is in your first bot in Python.
Invite it with just the permissions it needs
In “OAuth2” → “URL Generator”, tick “bot” and “applications.commands”, and in the permissions “Send Messages”, “Embed Links” and “Manage Roles”. Open the link it gives you and pick your server. No special intent is needed (the “Privileged Gateway Intents” stay off).
Put its role above the ones it will give
In “Server Settings” → “Roles”, drag the bot's role above the roles people will be able to pick. A bot can't give a role at its own level or above, even with “Manage Roles”. Other bots' roles and @everyone can never be given.
2. The code in Python (discord.py)
Three files in one folder. The token goes in .env, without quotes. You need Python 3.10 or newer.
discord.py
python-dotenvDISCORD_TOKEN=paste-your-token-hereimport os
import discord
from discord import app_commands
from dotenv import load_dotenv
load_dotenv() # reads the token from the .env file
# Only the default intents are needed: buttons arrive as interactions.
client = discord.Client(intents=discord.Intents.default())
tree = app_commands.CommandTree(client)
@tree.command(name="role-panel", description="Posts a message with buttons to pick roles")
@app_commands.guild_only()
@app_commands.default_permissions(manage_roles=True) # only visible to whoever manages roles
@app_commands.describe(
title="What goes above the buttons",
role1="A role people can pick",
role2="Another role (optional)",
role3="Another role (optional)",
role4="Another role (optional)",
role5="Another role (optional)",
)
async def role_panel(
interaction: discord.Interaction,
title: str,
role1: discord.Role,
role2: discord.Role | None = None,
role3: discord.Role | None = None,
role4: discord.Role | None = None,
role5: discord.Role | None = None,
):
roles = [r for r in (role1, role2, role3, role4, role5) if r]
mine = interaction.guild.me.top_role
# A bot can't give a role equal to or higher than its own, nor other bots' roles or @everyone.
impossible = [r.name for r in roles if r >= mine or r.managed or r.is_default()]
if impossible:
await interaction.response.send_message(
f"I can't give {', '.join(impossible)}: move my role above them in “Server Settings” → “Roles”.",
ephemeral=True,
)
return
view = discord.ui.View(timeout=None)
for role in roles:
# The role id goes in the button itself: no database needed.
view.add_item(discord.ui.Button(label=role.name, custom_id=f"rid:{role.id}"))
card = discord.Embed(title=title, description="Click a button to add or remove the role.", color=0x5B4AE8)
await interaction.channel.send(embed=card, view=view)
await interaction.response.send_message("Panel posted.", ephemeral=True)
@client.event
async def on_interaction(interaction: discord.Interaction):
# Every button click arrives here, also on panels posted before the bot restarted.
if interaction.type is not discord.InteractionType.component:
return
custom_id = interaction.data.get("custom_id", "")
if not custom_id.startswith("rid:") or interaction.guild is None:
return
role = interaction.guild.get_role(int(custom_id[4:]))
if role is None:
await interaction.response.send_message("That role no longer exists.", ephemeral=True)
return
try:
if role in interaction.user.roles:
await interaction.user.remove_roles(role, reason="Role panel")
text = f"I've removed {role.mention}."
else:
await interaction.user.add_roles(role, reason="Role panel")
text = f"You now have {role.mention}."
except discord.Forbidden:
text = "I can't give you that role: tell whoever manages the server (my role has to be above it)."
await interaction.response.send_message(text, ephemeral=True)
@client.event
async def on_ready():
await tree.sync() # publishes /role-panel
print(f"Logged in as {client.user}")
client.run(os.environ["DISCORD_TOKEN"])3. The same bot in JavaScript (discord.js)
With Node.js 18 or newer. Replies that only the clicker sees are marked with MessageFlags.Ephemeral, which is the current way in discord.js 14 (the old one, ephemeral: true, warns that it's going away).
{
"name": "role-bot",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"discord.js": "^14.17.0",
"dotenv": "^16.4.0"
}
}DISCORD_TOKEN=paste-your-token-hererequire("dotenv").config(); // reads the token from the .env file
const {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
Client,
EmbedBuilder,
Events,
GatewayIntentBits,
InteractionContextType,
MessageFlags,
PermissionFlagsBits,
SlashCommandBuilder,
} = require("discord.js");
// Only Guilds is needed: buttons arrive as interactions.
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const command = new SlashCommandBuilder()
.setName("role-panel")
.setDescription("Posts a message with buttons to pick roles")
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) // only visible to whoever manages roles
.addStringOption((o) => o.setName("title").setDescription("What goes above the buttons").setRequired(true))
.addRoleOption((o) => o.setName("role1").setDescription("Role").setRequired(true));
for (let n = 2; n <= 5; n++) command.addRoleOption((o) => o.setName(`role${n}`).setDescription("Another role (optional)"));
const privately = (content) => ({ content, flags: MessageFlags.Ephemeral });
client.on(Events.InteractionCreate, async (i) => {
if (!i.inCachedGuild()) return;
if (i.isChatInputCommand() && i.commandName === "role-panel") {
const roles = [1, 2, 3, 4, 5].map((n) => i.options.getRole(`role${n}`)).filter(Boolean);
const mine = i.guild.members.me.roles.highest;
// A bot can't give a role equal to or higher than its own, nor other bots' roles or @everyone.
const impossible = roles.filter((r) => r.managed || r.id === i.guildId || r.comparePositionTo(mine) >= 0);
if (impossible.length) {
const names = impossible.map((r) => r.name).join(", ");
return i.reply(privately(`I can't give ${names}: move my role above them in “Server Settings” → “Roles”.`));
}
// The role id goes in the button itself: no database needed.
const row = new ActionRowBuilder().addComponents(
roles.map((r) => new ButtonBuilder().setCustomId(`rid:${r.id}`).setLabel(r.name).setStyle(ButtonStyle.Secondary)),
);
const card = new EmbedBuilder()
.setColor(0x5b4ae8)
.setTitle(i.options.getString("title"))
.setDescription("Click a button to add or remove the role.");
await i.channel.send({ embeds: [card], components: [row] });
return i.reply(privately("Panel posted."));
}
// Every button click arrives here, also on panels posted before the bot restarted.
if (i.isButton() && i.customId.startsWith("rid:")) {
const role = i.guild.roles.cache.get(i.customId.slice(4));
if (!role) return i.reply(privately("That role no longer exists."));
let text;
try {
if (i.member.roles.cache.has(role.id)) {
await i.member.roles.remove(role, "Role panel");
text = `I've removed ${role}.`;
} else {
await i.member.roles.add(role, "Role panel");
text = `You now have ${role}.`;
}
} catch {
text = "I can't give you that role: tell whoever manages the server (my role has to be above it).";
}
await i.reply(privately(text));
}
});
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set([command.toJSON()]); // publishes /role-panel
console.log(`Logged in as ${c.user.tag}`);
});
client.login(process.env.DISCORD_TOKEN);4. Post your first panel
/role-panel is only visible to people with the manage roles permission. If you want others to use it, you change that without touching the code in “Server Settings” → “Integrations” → your bot.
Start the bot
On your computer: “pip install -r requirements.txt” and “python app.py” (or “npm install” and “node index.js”). Wait until it says “Logged in as…”. The command can take a few minutes to show up the first time.
Create the roles
In “Server Settings” → “Roles”, create the ones people will be able to pick (for example Minecraft, Valheim and Event pings) and keep the bot's role above all of them.
Type /role-panel in the channel
Give it a title (“What do you play?”) and pick from one to five roles. The bot posts the message with one button per role and replies “Panel posted.” only to you. If any role is above its own, it tells you and posts nothing.
Try it
Click a button: it gives you the role and tells you. Click it again and it removes it. For more than five roles, post another panel below (for example one for games and another for pings).
If something isn't working
“I can't give you that role”: the bot's role has ended up below it (for example, someone moved the roles after the panel was posted) or “Manage Roles” was taken away. Move it up and click again; you don't need to post the panel again.
Clicking says “This interaction failed”: the bot is offline. The buttons stay there even when the bot isn't running, but nobody answers. As soon as it's back online, the old panels work by themselves.
“That role no longer exists”: the role was deleted. Delete that panel and post a new one with the current roles. If you only rename the role, the button keeps working, but shows the old name until you post another panel.
A pings role is useful for mentioning in announcements: it only reaches whoever picked it. In “Server Settings” → “Roles” → that role you decide whether anyone can mention it or only whoever manages the server. And for a game role to unlock channels, give it permission to view those channels in its settings.
5. Keep it online all the time (for free)
Buttons have the same problem as any bot: if it lives on your computer, when you switch it off the panels stay in the channel but clicking says the interaction failed, and whoever joins your server at night is left without their roles.
On NapNodes: create the free bot (it starts on Node.js; if yours is the Python one, in “Settings” → “Language”, “Switch to Python”), upload the three files in “Files” and click “Start”. The libraries in requirements.txt or package.json install themselves at startup, and app.py or index.js is the startup file that comes set. Since the bot stores nothing, you can move it, reinstall it or switch it from Python to JavaScript and the posted panels keep working.
The free bot has 256 MB, plenty for a role bot, and you have to renew it with one click every week: if you forget, it goes offline until you start it (and after 14 days without renewing it's deleted, with a warning beforehand). If you'd rather not have to remember, the Mini plan (512 MB) stays online with nothing to renew (Discord bot pricing). The whole setup is in the Discord bot hosting guide.
Ideas to extend it
Exclusive roles (pick only one, like colors or regions): when giving one, remove the others from the same panel, which are on the message's other buttons. A dropdown menu instead of buttons, for panels of up to 25 roles in a single message. Emojis on the buttons (setEmoji in discord.js, emoji= in discord.py). Or a role that can only be picked after some time on the server, by checking the member's join date before giving it.
Frequently asked questions
How do I make a self-role bot for Discord?
With a command that posts a message with buttons, one per role, and an event that receives each click: if the person already has the role, it removes it; if not, it gives it. The full code, in Python and JavaScript, is in this guide.
Are reaction roles or buttons better?
Buttons. The bot doesn't need to store which emoji goes with which role or the reactions intent, it works the same after a restart and it privately tells whoever clicked whether it gave or removed the role.
Why doesn't my bot give roles?
It's almost always the hierarchy: the bot's role has to be above the role it gives, and the bot needs the “Manage Roles” permission. Other bots' roles and @everyone can't be given.
Do the buttons keep working if I restart the bot?
Yes, with this guide's code: each button carries its role's id and the bot handles any click, whether from a new panel or one posted before the restart. The bot just has to be online.
How many roles fit in a panel?
Five with this guide's code, which uses one row of buttons. For more, post several panels, or swap the buttons for a dropdown menu, which allows up to 25 options.
Shall we set it up for you?
Discord bot hosting from €1.50 a month, never oversold: your RAM is yours alone. And if you'd rather start without paying, there's a free Discord bot, no card needed.
Free tool: Discord colored text generator. Write on Discord in red, green or yellow, with backgrounds, bold and underline: the ansi block ready to paste, and the line for your bot.
Keep reading
- Discord Welcome Bot: Join Message and Auto Role (Python and JavaScript)
Build a Discord bot that greets new members and gives them a role automatically, in Python or JavaScript: the missing intent, role order and rules screening.
- Discord Music Bot: Make Your Own in Python or JavaScript
Why the big Discord music bots shut down and how to build your own with a queue and /play (discord.py or discord.js), with what voice requires since 2026.
- Your Discord bot token: where to keep it and what to do if it leaks
Where to keep your Discord bot token so it doesn't leak (.env and .gitignore) and what to do, in order, if you pushed it to GitHub or it got stolen.