Kick, ban and timeout commands for a Discord bot, in discord.py and discord.js

Four slash commands, timeout, kick, ban and unban, for each library. The part most examples leave out is checking who is asking. The bot acts with its own role, so without that check anyone who can kick can use the bot on people ranked above them.

9 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, which checked every command against members of different rank, the server owner, the moderator themself, a moderator without the permission, and a member Discord refuses to touch.

The check most examples skip

When the bot kicks someone, Discord checks the bot's role, not the moderator's. If the bot sits near the top of the role list, as moderation bots usually do, a member with Kick Members could use it to kick an admin they could never kick by hand. So every command below checks four things before it acts:

  • the target is not the server owner, whom nobody can kick, ban or time out;
  • the target is not the person asking;
  • the target's highest role is below the moderator's (the owner may act on anyone);
  • the target's highest role is below the bot's, or Discord would refuse anyway.

Discord also never lets a member with Administrator be timed out. The bot itself needs Moderate Members, Kick Members and Ban Members, and its role must sit above the people it acts on, in Server Settings, Roles.

default_permissions (discord.py) and setDefaultMemberPermissions (discord.js) hide each command from members who lack that permission. Server admins can change that under Server Settings, Integrations, so the code checks the permission again every time.

discord.py

Python
import datetime
import logging
import os

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

log = logging.getLogger(__name__)

class MyBot(commands.Bot):
    async def setup_hook(self):
        await self.tree.sync()

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

def cannot_act_on(interaction: discord.Interaction, member: discord.Member) -> str | None:
    """Why this command may not touch this member, or None if it may."""
    guild = interaction.guild
    if member.id == guild.owner_id:
        return "Nobody can do that to the server owner."
    if member.id == interaction.user.id:
        return "You cannot do that to yourself."
    if interaction.user.id != guild.owner_id and member.top_role >= interaction.user.top_role:
        return f"{member.display_name}'s highest role is not below yours."
    if member.top_role >= guild.me.top_role:
        return f"{member.display_name}'s highest role is not below mine. Move my role up in Server Settings, Roles."
    return None

@bot.tree.command(description="Time a member out")
@app_commands.describe(minutes="Up to 40320, which is 28 days", reason="Saved in the audit log")
@app_commands.default_permissions(moderate_members=True)
@app_commands.checks.has_permissions(moderate_members=True)
@app_commands.guild_only()
async def timeout(interaction: discord.Interaction, member: discord.Member,
                  minutes: app_commands.Range[int, 1, 40320], reason: str | None = None):
    if problem := cannot_act_on(interaction, member):
        return await interaction.response.send_message(problem, ephemeral=True)
    await member.timeout(datetime.timedelta(minutes=minutes), reason=reason)
    await interaction.response.send_message(f"{member.mention} is timed out for {minutes} minutes.")

@bot.tree.command(description="Kick a member")
@app_commands.default_permissions(kick_members=True)
@app_commands.checks.has_permissions(kick_members=True)
@app_commands.guild_only()
async def kick(interaction: discord.Interaction, member: discord.Member, reason: str | None = None):
    if problem := cannot_act_on(interaction, member):
        return await interaction.response.send_message(problem, ephemeral=True)
    await member.kick(reason=reason)
    await interaction.response.send_message(f"Kicked {member.display_name}.")

@bot.tree.command(description="Ban a member and delete their last day of messages")
@app_commands.default_permissions(ban_members=True)
@app_commands.checks.has_permissions(ban_members=True)
@app_commands.guild_only()
async def ban(interaction: discord.Interaction, member: discord.Member, reason: str | None = None):
    if problem := cannot_act_on(interaction, member):
        return await interaction.response.send_message(problem, ephemeral=True)
    await member.ban(reason=reason, delete_message_seconds=24 * 60 * 60)
    await interaction.response.send_message(f"Banned {member.display_name}.")

@bot.tree.command(description="Lift a ban")
@app_commands.describe(user_id="The banned user's ID")
@app_commands.default_permissions(ban_members=True)
@app_commands.checks.has_permissions(ban_members=True)
@app_commands.guild_only()
async def unban(interaction: discord.Interaction, user_id: str, reason: str | None = None):
    if not user_id.isdigit():
        return await interaction.response.send_message("That is not a user ID.", ephemeral=True)
    try:
        await interaction.guild.unban(discord.Object(id=int(user_id)), reason=reason)
    except discord.NotFound:
        return await interaction.response.send_message("That user is not banned.", ephemeral=True)
    await interaction.response.send_message(f"Unbanned <@{user_id}>.")

@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
    if isinstance(error, app_commands.MissingPermissions):
        msg = "You do not have permission to use that."
    elif isinstance(error, app_commands.CommandInvokeError) and isinstance(error.original, discord.Forbidden):
        msg = "Discord did not let me. Check my role has the permission and sits above theirs."
    else:
        msg = "Something went wrong."
        log.error("command failed", exc_info=error)
    if interaction.response.is_done():
        await interaction.followup.send(msg, ephemeral=True)
    else:
        await interaction.response.send_message(msg, ephemeral=True)

bot.run(os.environ["BOT_TOKEN"])
  • Timeouts last from a minute to 28 days, which is where the Range stops. Discord lifts them by itself; member.timeout(None) lifts one early.
  • Bans delete messages. In discord.py, ban() deletes the member's last day of messages unless you pass delete_message_seconds, up to 604800 (seven days). Pass 0 to keep them.
  • Unban takes an ID as text. A banned user is not in the server to pick from the member list, and an integer option cannot hold an ID: Discord's integers stop at 253, and IDs are bigger.
  • The reason shows in the server's audit log. The log names the bot as the one who acted, so to see which moderator used it, put their name in the reason: reason=f"{interaction.user}: {reason}".

discord.js

The commands, registered once with the deploy script (the slash commands guide covers it):

JavaScript
// deploy-commands.js
const {
  InteractionContextType, PermissionFlagsBits, REST, Routes, SlashCommandBuilder,
} = require("discord.js");

const reason = (o) => o.setName("reason").setDescription("Saved in the audit log");
const member = (o) => o.setName("member").setDescription("Who").setRequired(true);
const command = (name, description, permission) =>
  new SlashCommandBuilder().setName(name).setDescription(description)
    .setDefaultMemberPermissions(permission).setContexts(InteractionContextType.Guild);

const commands = [
  command("timeout", "Time a member out", PermissionFlagsBits.ModerateMembers)
    .addUserOption(member)
    .addIntegerOption((o) => o.setName("minutes").setDescription("Up to 40320, which is 28 days")
      .setRequired(true).setMinValue(1).setMaxValue(40320))
    .addStringOption(reason),
  command("kick", "Kick a member", PermissionFlagsBits.KickMembers).addUserOption(member).addStringOption(reason),
  command("ban", "Ban a member and delete their last day of messages", PermissionFlagsBits.BanMembers)
    .addUserOption(member).addStringOption(reason),
  command("unban", "Lift a ban", PermissionFlagsBits.BanMembers)
    .addStringOption((o) => o.setName("user_id").setDescription("The banned user's ID").setRequired(true))
    .addStringOption(reason),
].map((c) => c.toJSON());

const rest = new REST().setToken(process.env.BOT_TOKEN);
rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands })
  .then((data) => console.log(`Registered ${data.length} commands`));

And the bot:

JavaScript
// index.js
const {
  Client, Events, GatewayIntentBits, MessageFlags, PermissionFlagsBits, RESTJSONErrorCodes,
} = require("discord.js");

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

const NEEDS = {
  timeout: PermissionFlagsBits.ModerateMembers,
  kick: PermissionFlagsBits.KickMembers,
  ban: PermissionFlagsBits.BanMembers,
  unban: PermissionFlagsBits.BanMembers,
};

const said = (content) => ({ content }); // everyone in the channel sees it
const only = (content) => ({ content, flags: MessageFlags.Ephemeral }); // only the moderator does

// Why this command may not touch this member, or null if it may.
function cannotActOn(interaction, member) {
  const { guild } = interaction;
  if (member.id === guild.ownerId) return "Nobody can do that to the server owner.";
  if (member.id === interaction.user.id) return "You cannot do that to yourself.";
  if (interaction.user.id !== guild.ownerId
      && member.roles.highest.comparePositionTo(interaction.member.roles.highest) >= 0) {
    return `${member.displayName}'s highest role is not below yours.`;
  }
  if (member.roles.highest.comparePositionTo(guild.members.me.roles.highest) >= 0) {
    return `${member.displayName}'s highest role is not below mine. Move my role up in Server Settings, Roles.`;
  }
  return null;
}

async function moderate(interaction) {
  const reason = interaction.options.getString("reason") ?? undefined;
  if (interaction.commandName === "unban") {
    const id = interaction.options.getString("user_id");
    if (!/^\d{17,20}$/.test(id)) return only("That is not a user ID.");
    await interaction.guild.members.unban(id, reason);
    return said(`Unbanned <@${id}>.`);
  }
  const member = interaction.options.getMember("member");
  if (!member) return only("They are not in this server.");
  const problem = cannotActOn(interaction, member);
  if (problem) return only(problem);
  if (interaction.commandName === "timeout") {
    const minutes = interaction.options.getInteger("minutes");
    await member.timeout(minutes * 60 * 1000, reason);
    return said(`${member} is timed out for ${minutes} minutes.`);
  }
  if (interaction.commandName === "kick") {
    await member.kick(reason);
    return said(`Kicked ${member.displayName}.`);
  }
  await member.ban({ reason, deleteMessageSeconds: 24 * 60 * 60 });
  return said(`Banned ${member.displayName}.`);
}

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand() || !(interaction.commandName in NEEDS)) return;
  if (!interaction.inCachedGuild()) return;
  if (!interaction.memberPermissions.has(NEEDS[interaction.commandName])) {
    return interaction.reply(only("You do not have permission to use that."));
  }
  let answer;
  try {
    answer = await moderate(interaction);
  } catch (err) {
    if (err.code === RESTJSONErrorCodes.UnknownBan) answer = only("That user is not banned.");
    else if (err.code === RESTJSONErrorCodes.MissingPermissions) {
      answer = only("Discord did not let me. Check my role has the permission and sits above theirs.");
    } else {
      console.error(err);
      answer = only("Something went wrong.");
    }
  }
  await interaction.reply(answer);
});

client.login(process.env.BOT_TOKEN);
  • discord.js deletes no messages on a ban unless you pass deleteMessageSeconds, the opposite of discord.py. The example passes a day, like the Python one.
  • member.kickable, bannable and moderatable exist, but they only compare the target with the bot. They say nothing about the moderator, which is why cannotActOn checks both.
  • timeout() takes milliseconds. member.timeout(null) lifts one early.
  • Successes are posted for the channel to see; refusals go only to the moderator (MessageFlags.Ephemeral).

Common problems

  • 403 Forbidden (error code: 50013): Missing Permissions: the bot lacks the permission, its role is not above the target's, or the target has Administrator (for a timeout). Both examples turn it into a message instead of a crash.
  • Unknown Ban (10026) on unban: that user is not banned here.
  • The commands are missing for a moderator: they lack the permission the command asks for, or an admin changed it under Server Settings, Integrations.
  • "The application did not respond": the bot is not running, or the command threw before answering. Look in the console.

For more on the slash command parts, see the discord.py and discord.js slash commands guides. To keep a record of deleted and edited messages for your moderators, see message logs.

Press / or Ctrl K on any page to search.