Skip to content

Context Menus

Not every moderation action starts with a slash command. Sometimes it’s faster to right-click a user and pick “Warn User” from the menu, or right-click a suspicious message and hit “Flag Message.” Discord calls these context menu commands, and Warden gives them first-class support.

Context menus come in two flavors: user (right-click a user or their avatar) and message (right-click a message). They appear in the “Apps” section of the right-click menu, and they work everywhere — in the member list, in chat, even on profiles.

The big difference from slash commands is that context menus have no options or arguments. Instead, they receive a target — the user or message that was right-clicked. Everything else you already know applies: middleware, permissions, cooldowns, error handling, and dependency injection all work exactly the same way.

A user context menu fires when someone right-clicks a user and selects your command from the Apps menu. The interaction gives you the target user directly:

import {context, ContextMenu, Params, reply, Embed} from '@warden/core';
import {UserContextMenuCommandInteraction} from 'discord.js';
@context({name: 'Warn User', type: 'user'})
@permissions({user: ['ModerateMembers']})
export default class WarnUserContext implements ContextMenu {
constructor(private caseService: CaseService) {}
public async execute(
interaction: UserContextMenuCommandInteraction,
params: Params,
): Promise<void> {
const target = interaction.targetUser;
const caseNumber = await this.caseService.createWarning(
interaction.guildId!,
target.id,
'Warned via context menu',
interaction.user.id,
);
await reply(interaction, Embed.success(`Warned ${target.username} — case #${caseNumber}`));
}
}

The interaction.targetUser is the user who was right-clicked — not the user who clicked the menu. This is the moderation target. For a UserContextMenuCommandInteraction, you also have access to interaction.targetMember (a GuildMember) when the command is used in a server.

A message context menu fires when someone right-clicks a message. The interaction gives you the full message object:

import {context, ContextMenu, Params, reply, Embed} from '@warden/core';
import {MessageContextMenuCommandInteraction} from 'discord.js';
@context({name: 'Flag Message', type: 'message'})
@permissions({user: ['ModerateMembers']})
export default class FlagMessageContext implements ContextMenu {
constructor(private caseService: CaseService, private modLog: ModLogService) {}
public async execute(
interaction: MessageContextMenuCommandInteraction,
params: Params,
): Promise<void> {
const message = interaction.targetMessage;
const caseNumber = await this.caseService.createFlag(
interaction.guildId!,
message.id,
message.channelId,
message.author.id,
interaction.user.id,
);
await reply(interaction, Embed.success(`Message flagged for review — case #${caseNumber}`));
await this.modLog.send(
Embed.warning(`${interaction.user.username} flagged a message`)
.addFields(
{name: 'Author', value: message.author.username, inline: true},
{name: 'Channel', value: `<#${message.channelId}>`, inline: true},
{name: 'Case', value: `#${caseNumber}`, inline: true},
{name: 'Content', value: message.content?.slice(0, 1024) || '*no text content*'},
)
.setTimestamp(),
);
}
}

The interaction.targetMessage gives you the full Message object — content, author, attachments, embeds, everything. This makes it easy to capture evidence when flagging content for review.

ParameterTypeRequiredDescription
namestringYesThe name shown in the right-click menu (supports spaces and mixed case)
type'user' | 'message'YesWhether this appears on users or messages
middlewareMiddleware[]NoTyped middleware to run before execute()

A few things to keep in mind about context menu names:

  • Unlike slash commands, context menu names can contain spaces and mixed case — “Warn User” and “Flag Message” are perfectly valid
  • Discord allows a maximum of 5 user and 5 message context menu commands per bot
  • Names must be unique within their type — you can’t have two user context menus both named “Warn User”

The class must implement the ContextMenu interface, which requires an execute() method. The interaction type depends on the type field:

  • type: 'user' receives UserContextMenuCommandInteraction
  • type: 'message' receives MessageContextMenuCommandInteraction

Both receive a Params as the second argument, which may be populated by middleware or plugins.

Context menus support the exact same decorator stack as slash commands. You may add permissions, cooldowns, middleware, and error handlers:

@context({
name: 'Quick Mute',
type: 'user',
middleware: [EnsureGuildIsAvailable, NotSelf],
})
@permissions({user: ['ModerateMembers'], bot: ['ModerateMembers']})
@cooldown({duration: 10, scope: 'user', message: 'Please wait before muting another user.'})
export default class QuickMuteContext implements ContextMenu {
public async execute(
interaction: UserContextMenuCommandInteraction,
params: Params,
): Promise<void> {
const target = interaction.targetMember as GuildMember;
await target.timeout(10 * 60 * 1000, 'Quick mute via context menu');
await reply(interaction, Embed.success(`Muted ${target.user.username} for 10 minutes.`));
}
}

Warden ships a built-in NotSelf middleware for exactly this — import it from @warden/core and add it to your decorator. It prevents moderators from accidentally muting themselves (a common pitfall with right-click commands, since it’s easy to click your own name). The cooldown prevents spam-muting. All the same patterns from the Middleware guide apply.

Context menus have no options of their own — there’s no way to ask the user for a reason or severity level through the right-click menu itself. But you can show a modal immediately after the context menu fires. This is the most common pattern for moderation context menus:

1. The context menu shows a modal:

import {context, ContextMenu, Params, ModalForm, TextInput} from '@warden/core';
import {UserContextMenuCommandInteraction} from 'discord.js';
@context({name: 'Warn User', type: 'user'})
@permissions({user: ['ModerateMembers']})
export default class WarnUserContext implements ContextMenu {
public async execute(
interaction: UserContextMenuCommandInteraction,
params: Params,
): Promise<void> {
const target = interaction.targetUser;
await interaction.showModal(
ModalForm(`warn-reason/${target.id}`, `Warn ${target.username}`)
.addField(TextInput.paragraph('reason', 'Reason for the warning').required().maxLength(500))
.addField(TextInput.short('severity', 'Severity (low, medium, high)').required()),
);
}
}

2. The modal handler processes the warning:

import {modal, Modal, Params, reply, Embed} from '@warden/core';
import {ModalSubmitInteraction} from 'discord.js';
@modal({customId: 'warn-reason/:userId'})
export default class WarnReasonModal implements Modal {
constructor(private caseService: CaseService, private modLog: ModLogService) {}
public async execute(interaction: ModalSubmitInteraction, params: Params): Promise<void> {
const userId = params.get('userId')!;
const reason = interaction.fields.getTextInputValue('reason');
const severity = interaction.fields.getTextInputValue('severity');
const caseNumber = await this.caseService.createWarning(
interaction.guildId!, userId, reason, interaction.user.id, severity,
);
await reply(interaction, Embed.success(`Warning issued — case #${caseNumber}`)
.addFields(
{name: 'User', value: `<@${userId}>`, inline: true},
{name: 'Severity', value: severity, inline: true},
{name: 'Reason', value: reason},
)
.setTimestamp(),
);
await this.modLog.send(
Embed.warning(`${interaction.user.username} warned <@${userId}>`)
.addFields(
{name: 'Reason', value: reason},
{name: 'Severity', value: severity, inline: true},
{name: 'Case', value: `#${caseNumber}`, inline: true},
)
.setTimestamp(),
);
}
}

The data flows through the dynamic custom ID: the context menu encodes the target user ID into the modal’s custom ID (warn-reason/${target.id}), and the modal handler retrieves it from the Params (params.get('userId')). No temporary storage needed. For more on dynamic custom IDs, see the Components guide.

You can generate a context menu class with the Warden CLI:

Terminal window
warden make:context WarnUser --type user
warden make:context FlagMessage --type message

This creates a file with the correct decorator, interface, interaction type, and a placeholder execute() method. The --type flag determines whether the generated class uses UserContextMenuCommandInteraction or MessageContextMenuCommandInteraction.

Here’s a real-world pattern: a “Report Message” context menu that regular members (not just moderators) can use to flag content for the mod team. It shows a modal asking for a reason, creates a report, and notifies the mod channel:

The context menu — src/context/ReportMessageContext.ts:

import {context, ContextMenu, Params, ModalForm, TextInput} from '@warden/core';
import {MessageContextMenuCommandInteraction} from 'discord.js';
@context({name: 'Report Message', type: 'message'})
@cooldown({duration: 60, scope: 'user', message: 'You can only report once per minute.'})
export default class ReportMessageContext implements ContextMenu {
public async execute(
interaction: MessageContextMenuCommandInteraction,
params: Params,
): Promise<void> {
const message = interaction.targetMessage;
await interaction.showModal(
ModalForm(
`report-message/${message.id}/${message.channelId}/${message.author.id}`,
'Report Message',
)
.addField(TextInput.paragraph('reason', 'Why are you reporting this message?').required()),
);
}
}

The modal handler — src/modals/ReportMessageModal.ts:

import {modal, Modal, Params, reply, Embed} from '@warden/core';
import {ModalSubmitInteraction} from 'discord.js';
@modal({customId: 'report-message/:messageId/:channelId/:authorId'})
export default class ReportMessageModal implements Modal {
constructor(private reportService: ReportService, private modLog: ModLogService) {}
public async execute(interaction: ModalSubmitInteraction, params: Params): Promise<void> {
const messageId = params.get('messageId')!;
const channelId = params.get('channelId')!;
const authorId = params.get('authorId')!;
const reason = interaction.fields.getTextInputValue('reason');
const reportNumber = await this.reportService.create({
guildId: interaction.guildId!,
messageId,
channelId,
authorId,
reporterId: interaction.user.id,
reason,
});
await reply(interaction, Embed.success(
`Thank you for your report (#${reportNumber}). The mod team will review it shortly.`,
));
await this.modLog.send(
Embed.warning(`New message report #${reportNumber}`)
.addFields(
{name: 'Reported by', value: `<@${interaction.user.id}>`, inline: true},
{name: 'Author', value: `<@${authorId}>`, inline: true},
{name: 'Channel', value: `<#${channelId}>`, inline: true},
{name: 'Reason', value: reason},
{name: 'Jump to message', value: `[Click here](https://discord.com/channels/${interaction.guildId}/${channelId}/${messageId})`},
)
.setTimestamp(),
);
}
}

Notice the cooldown on the context menu — this prevents abuse from users who might spam-report messages. The modal’s dynamic custom ID carries the message metadata through to the handler, and the mod log includes a jump link so moderators can go straight to the reported message.