Log deleted and edited messages with a Discord bot, in discord.py and discord.js

A staff channel that shows every deleted message and every edit, with what it said before. Here is the whole bot for each library, why some deletions arrive without their text, and the link previews that look like edits.

7 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, deleting and editing messages the bot had seen and messages from before it started, to check what reaches the log and what does not.

Before the code

  • Message Content. Without this intent a bot sees messages with empty text, so there is nothing to log. It is privileged: switch it on in the code, as below, and in the Developer Portal on the Bot page. Once the bot is in 100 servers, Discord asks you to apply for it.
  • A staff-only channel for the log, where the bot can send messages and embeds. Copy its ID with Developer Mode on (User Settings, Advanced, then right-click the channel).
  • Say so in the rules. A log keeps what people chose to take back. Members should know it exists.

What a bot can and cannot know

Discord tells a bot only which message was deleted, not what it said. The text comes from the bot's own memory of messages it has seen: discord.py keeps the last 1,000 across all channels, discord.js the last 200 in each channel. A message from before the bot last started, or older than that, is deleted without its text, and the log can only say so. The same goes for the old text of an edit.

The event does not say who deleted the message either. When a moderator deletes someone else's message, the server's audit log records it (reading it needs View Audit Log); when people delete their own, nothing does.

discord.py

Python
import os

import discord
from discord.ext import commands

LOG_CHANNEL_ID = 123456789012345678  # a channel only staff can see

intents = discord.Intents.default()
intents.message_content = True  # the Message Content intent: switch it on in the Developer Portal too

bot = commands.Bot(command_prefix="!", intents=intents)

def log_channel(guild_id: int | None) -> discord.TextChannel | None:
    channel = bot.get_channel(LOG_CHANNEL_ID)
    return channel if channel and channel.guild.id == guild_id else None

@bot.event
async def on_raw_message_delete(payload: discord.RawMessageDeleteEvent):
    log = log_channel(payload.guild_id)
    message = payload.cached_message
    if log is None or payload.channel_id == log.id or (message and message.author.bot):
        return
    embed = discord.Embed(title="Message deleted", colour=discord.Colour.red())
    embed.add_field(name="Channel", value=f"<#{payload.channel_id}>")
    if message is None:
        embed.description = "Sent before the bot started, or too long ago: its text is not known."
    else:
        embed.add_field(name="Author", value=message.author.mention)
        embed.description = message.content[:4000] or "(no text)"
    await log.send(embed=embed)

@bot.event
async def on_raw_message_edit(payload: discord.RawMessageUpdateEvent):
    log = log_channel(payload.guild_id)
    after, before = payload.message, payload.cached_message
    if log is None or payload.channel_id == log.id or after.author.bot:
        return
    if after.edited_at is None:
        return  # a link preview loading, not an edit
    if before is not None and before.content == after.content:
        return
    embed = discord.Embed(title="Message edited", url=after.jump_url, colour=discord.Colour.orange())
    embed.add_field(name="Author", value=after.author.mention)
    embed.add_field(name="Before", value=(before.content[:1024] or "(no text)") if before else "(not known)", inline=False)
    embed.add_field(name="After", value=after.content[:1024] or "(no text)", inline=False)
    await log.send(embed=embed)

bot.run(os.environ["BOT_TOKEN"])
  • The raw events. on_message_delete and on_message_edit only fire for messages in the bot's memory. The raw ones fire for every message, and carry the remembered copy as cached_message when there is one.
  • payload.message on an edit is the message as it is now; it needs discord.py 2.5 or newer.

discord.js

JavaScript
const { Client, Colors, EmbedBuilder, Events, GatewayIntentBits, Partials } = require("discord.js");

const LOG_CHANNEL_ID = "123456789012345678"; // a channel only staff can see

const client = new Client({
  // MessageContent is privileged: switch it on in the Developer Portal too.
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
  partials: [Partials.Message], // deletes and edits of messages the bot never had in memory
});

function logChannel(guild) {
  const channel = guild?.channels.cache.get(LOG_CHANNEL_ID);
  return channel?.isTextBased() ? channel : null;
}

client.on(Events.MessageDelete, async (message) => {
  const log = logChannel(message.guild);
  if (!log || message.channelId === log.id || message.author?.bot) return;
  const embed = new EmbedBuilder().setTitle("Message deleted").setColor(Colors.Red)
    .addFields({ name: "Channel", value: `${message.channel}`, inline: true });
  if (message.partial) {
    embed.setDescription("Sent before the bot started, or too long ago: its text is not known.");
  } else {
    embed.addFields({ name: "Author", value: `${message.author}`, inline: true })
      .setDescription(message.content.slice(0, 4000) || "(no text)");
  }
  await log.send({ embeds: [embed] });
});

client.on(Events.MessageUpdate, async (before, after) => {
  const log = logChannel(after.guild);
  if (!log || after.channelId === log.id || after.author?.bot) return;
  if (!after.editedTimestamp) return; // a link preview loading, not an edit
  if (!before.partial && before.content === after.content) return;
  const embed = new EmbedBuilder().setTitle("Message edited").setURL(after.url).setColor(Colors.Orange)
    .addFields(
      { name: "Author", value: `${after.author}` },
      { name: "Before", value: before.partial ? "(not known)" : before.content.slice(0, 1024) || "(no text)" },
      { name: "After", value: after.content.slice(0, 1024) || "(no text)" },
    );
  await log.send({ embeds: [embed] });
});

client.login(process.env.BOT_TOKEN);
  • Why Partials.Message. discord.js only fires MessageDelete and MessageUpdate for messages it has in memory. With the partial, it fires for the others too, with a message whose text is unknown (message.partial is true). Without it, the test run logged nothing at all for a message from before the bot started.
  • For an edit, after has the new text even when before is a partial, because Discord sends the whole edited message.

Edits that are not edits

When someone posts a link, Discord loads its preview a moment later and sends the bot an update for the message, with the same text. Both examples skip an update without an edit time (edited_at, editedTimestamp), and one whose text did not change, so the log only shows real edits.

Common problems

  • Every log says "(no text)": Message Content is off in the code or the portal.
  • Deletions show "its text is not known": the message was older than the bot's memory, or from before it last started. That is the limit, not a bug.
  • Nothing is logged for older messages in discord.js: Partials.Message is missing.
  • A purge logs nothing: deleting many messages at once arrives as one bulk event, on_raw_bulk_message_delete or MessageBulkDelete, which these examples do not handle.
  • 50013 Missing Permissions when logging: the bot cannot send messages or embeds in the log channel.

Press / or Ctrl K on any page to search.