By the end of this guide you'll have a working Discord moderation bot that can kick, ban, time members out and warn them, with every action written to a mod-log channel. We'll build it twice, once in Python with discord.py and once in JavaScript with discord.js, so you can pick the language you're comfortable with. This is aimed at people who can already run a script and edit a config file but have not built a bot that takes real moderation actions yet.
What your bot needs before it can moderate
A moderation bot is different from a fun bot. It actually changes the server state, so Discord is strict about what you're allowed to do. Two things decide whether an action works: the bot's intents and its permissions.
Intents tell Discord which events your bot wants to receive. For moderation you mostly need the basics plus the members intent, which lets the bot see who is in the server and resolve their roles. Open the Discord Developer Portal, click your application, go to the Bot tab, and turn on Server Members Intent. You don't need the message content intent if you're using slash commands, and slash commands are what we'll use here.
Permissions are what the bot can do once it's in a server. When you generate the invite link, give it these:
- Kick Members
- Ban Members
- Moderate Members (this is the one that controls timeouts)
- View Channels and Send Messages so it can post to the log
One rule trips up everyone, so learn it now. A bot can only act on a member whose highest role sits below the bot's own highest role. If your bot's role is near the bottom of the list, it can't ban anyone above it, including admins. Drag the bot's role up in Server Settings, Roles so it sits above the people it needs to manage.
Step 1: Set up the project
Create a folder and keep your token in an environment variable. Never paste the token straight into your code, and never commit it to git.
For Python, install the library:
python -m pip install -U discord.py
For Node, set up a project and install discord.js:
npm init -y
npm install discord.js
Put your token in a file the code reads at startup. In Python you can export it in your shell, and in Node a .env file with the dotenv package is common. Either way the token stays out of the source.
Step 2: A bot that registers slash commands
Slash commands are the modern way to take input. They give you a nice UI, autocomplete, and Discord checks permissions for you. Here's the discord.py skeleton with a kick, ban and timeout command.
import os
import discord
from discord import app_commands
from discord.ext import commands
intents = discord.Intents.default()
intents.members = True
bot = commands.Bot(command_prefix="!", intents=intents)
LOG_CHANNEL_ID = 123456789012345678 # your mod-log channel id
@bot.event
async def setup_hook():
await bot.tree.sync() # registers slash commands with Discord
@bot.tree.command(description="Kick a member")
@app_commands.describe(member="Who to kick", reason="Why")
async def kick(interaction: discord.Interaction, member: discord.Member, reason: str = "No reason given"):
await interaction.response.defer(ephemeral=True)
await member.kick(reason=reason)
await interaction.followup.send(f"Kicked {member} for: {reason}")
bot.run(os.environ["DISCORD_TOKEN"])
The discord.js version does the same job. You register the commands with Discord, then handle them when an interaction arrives.
const { Client, GatewayIntentBits, PermissionFlagsBits } = require('discord.js');
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === 'kick') {
const member = interaction.options.getMember('member');
const reason = interaction.options.getString('reason')?? 'No reason given';
await interaction.deferReply({ ephemeral: true });
await member.kick(reason);
await interaction.editReply(`Kicked ${member.user.tag} for: ${reason}`);
}
});
client.login(process.env.DISCORD_TOKEN);
Registering the actual command options (the member and reason fields) in discord.js is usually done in a separate deploy script with SlashCommandBuilder and the REST API. Run that once whenever you add or change a command.
Step 3: Check the caller's permissions first
Never assume the person running a command is allowed to. Even if you set command permissions in the server, check inside the code so a misconfiguration can't hand a normal member a ban button. In discord.py you can use a decorator that fails cleanly if the caller lacks the permission.
@bot.tree.command(description="Ban a member")
@app_commands.checks.has_permissions(ban_members=True)
@app_commands.describe(member="Who to ban", reason="Why")
async def ban(interaction: discord.Interaction, member: discord.Member, reason: str = "No reason given"):
await interaction.response.defer(ephemeral=True)
await member.ban(reason=reason)
await interaction.followup.send(f"Banned {member} for: {reason}")
In discord.js you check the member's permissions by hand at the top of the handler:
if (!interaction.memberPermissions.has(PermissionFlagsBits.BanMembers)) {
return interaction.reply({ content: 'You cannot ban members.', ephemeral: true });
}
You should also stop someone moderating a person above themselves in the role list, and stop the bot trying to action someone above the bot. A quick guard catches both:
if (member.roles.highest.position >= interaction.member.roles.highest.position) {
return interaction.reply({ content: 'That member ranks at or above you.', ephemeral: true });
}
Step 4: Mute someone with a timeout
Discord has a built in timeout, sometimes called "communication disabled until". A muted member can't talk or join voice until the clock runs out, and you don't need a special muted role. The maximum is 28 days. In discord.py you pass a duration:
import datetime
@bot.tree.command(description="Time a member out")
@app_commands.checks.has_permissions(moderate_members=True)
@app_commands.describe(member="Who", minutes="How many minutes", reason="Why")
async def timeout(interaction: discord.Interaction, member: discord.Member, minutes: int, reason: str = "No reason given"):
await interaction.response.defer(ephemeral=True)
until = datetime.timedelta(minutes=minutes)
await member.timeout(until, reason=reason)
await interaction.followup.send(f"Timed out {member} for {minutes} min: {reason}")
The discord.js equivalent takes the duration in milliseconds:
const minutes = interaction.options.getInteger('minutes');
await member.timeout(minutes * 60 * 1000, reason);
await interaction.editReply(`Timed out ${member.user.tag} for ${minutes} min.`);
To remove a timeout early, call member.timeout(None) in Python or member.timeout(null) in Node.
Step 5: A simple warn system
Warnings are softer than a kick. You want them to stick around so a repeat offender's history is visible. For a small bot, a JSON file is fine. Store warnings keyed by user id. Here's the Python version.
import json
def load_warns():
try:
with open("warns.json") as f:
return json.load(f)
except FileNotFoundError:
return {}
def save_warns(data):
with open("warns.json", "w") as f:
json.dump(data, f)
@bot.tree.command(description="Warn a member")
async def warn(interaction: discord.Interaction, member: discord.Member, reason: str):
data = load_warns()
user_warns = data.setdefault(str(member.id), [])
user_warns.append(reason)
save_warns(data)
await interaction.response.send_message(
f"Warned {member}. They now have {len(user_warns)} warning(s).", ephemeral=True
)
The Node version follows the same shape with fs.readFileSync and fs.writeFileSync, storing an array per user id. A JSON file works until the bot gets busy. Once you have a few thousand entries or you run the bot on more than one process, move to SQLite, which is a single small file and handles concurrent reads without you doing much extra work.
Step 6: Log every action to a mod-log channel
A moderation bot that leaves no trail is a problem waiting to happen. Send a message to a dedicated channel every time the bot kicks, bans, times out or warns someone. Record who did it, who it was done to, the reason, and the time. A helper keeps this tidy in discord.py:
async def log_action(action, moderator, target, reason):
channel = bot.get_channel(LOG_CHANNEL_ID)
if channel is None:
return
embed = discord.Embed(title=f"Member {action}", color=discord.Color.red())
embed.add_field(name="User", value=str(target))
embed.add_field(name="Moderator", value=str(moderator))
embed.add_field(name="Reason", value=reason, inline=False)
embed.timestamp = discord.utils.utcnow()
await channel.send(embed=embed)
Call log_action("banned", interaction.user, member, reason) right after the ban goes through. In discord.js you build a similar embed with EmbedBuilder and send it to the channel you fetch by id. Keep the log channel private so only your staff can read it.
Step 7: Handle errors gracefully
Things will go wrong, and a crashed command leaves the user staring at "This interaction failed". Catch errors and reply with something useful. In discord.py you can attach a global handler to the command tree:
@bot.tree.error
async def on_app_error(interaction: discord.Interaction, error):
if isinstance(error, app_commands.MissingPermissions):
msg = "You don't have permission to do that."
elif isinstance(error, discord.Forbidden):
msg = "I can't do that. Check my role is above the target and I have the permission."
else:
msg = f"Something went wrong: {error}"
if interaction.response.is_done():
await interaction.followup.send(msg, ephemeral=True)
else:
await interaction.response.send_message(msg, ephemeral=True)
In discord.js, wrap the action in a try and catch block and read the error code. A 50013 means "Missing Permissions" and almost always points back to the role hierarchy, not your code.
Troubleshooting
These are the errors we see most often when people first ship a moderation bot.
| Symptom | Cause | Fix |
|---|---|---|
| Missing Permissions (error 50013) | The bot's role is too low, or it lacks the permission | Drag the bot's role above the target's role and confirm Kick, Ban and Moderate Members are granted |
| Cannot ban an admin or owner | That person's top role is at or above the bot's | The bot can never action someone ranked higher than itself. The owner can't be banned at all |
| Slash commands don't appear | Commands were never synced, or you're waiting on the global cache | Sync to one guild while testing for instant updates. Global sync can take up to an hour |
| This interaction failed | You took longer than 3 seconds to respond | Call defer first, then send your real answer with a followup |
| member comes back as None or null | The Server Members intent is off | Enable it in the Developer Portal and in your intents in code |
| Privileged intent error on startup | You requested an intent you didn't enable in the portal | Turn the intent on in the Bot tab, then restart |
If a command works in your test server but not a friend's, the usual culprit is role position. Have them check where the bot's role sits.
Where to go from here
You now have the four actions that cover most day to day moderation, with permission checks, a warning store and a log of everything. A few sensible next steps: move warnings into SQLite once the JSON file feels heavy, add a /warnings command so staff can read a user's history, and auto time-out anyone who hits a warning threshold. When you're ready to keep it online around the clock, run it on a small VPS or a managed bot host like the one we offer at Bytte.cloud so it restarts on its own and stays up while you sleep. Keep the token secret, keep the bot's role high enough, and test new commands in a private server before you let them loose.



