|
| 1 | +--- |
| 2 | +title: Context Menu Commands |
| 3 | +description: | |
| 4 | + Context menu commands appear in Discord's right-click Apps submenu for users and messages. |
| 5 | + Learn the command-handler pattern, user vs message targeting, parameter injection, permission control, and module registration. |
| 6 | +order: 1 |
| 7 | +category: Context Menu |
| 8 | +--- |
| 9 | + |
| 10 | +## What context menu commands are |
| 11 | + |
| 12 | +Context menu commands are application commands that Discord exposes in the right-click context menu on users and messages rather than through the `/` slash picker. When a user right-clicks a server member and opens the _Apps_ submenu, any registered _user_ context menu commands appear there. When a user right-clicks a message, any registered _message_ context menu commands appear instead. Discord calls both types _application commands_, but their dispatch path and the data they carry are completely different from slash commands. |
| 13 | + |
| 14 | +The key distinction from slash commands is that context menu commands have no options. The interaction itself carries the target — either the `User` and optional `GuildMember` for a user command, or the `Message` for a message command — and the handler accesses that target directly from the interaction object. There is no `@SlashOpt()` injection and no option schema to define. |
| 15 | + |
| 16 | +Spraxium implements context menu commands with the same command-handler separation pattern used by slash commands. A _@ContextMenuCommand_ class declares the command metadata, and a separate _@ContextMenuCommandHandler_ class implements the handler logic. Guards, permissions, and the exception pipeline all work the same way they do for slash commands. |
| 17 | + |
| 18 | +## Defining a context menu command |
| 19 | + |
| 20 | +The _@ContextMenuCommand_ decorator accepts a config object with the command _name_ and _type_. The name is what appears in the Discord Apps submenu — it can contain spaces, up to 32 characters. The type is either `'user'` for user-targeted commands or `'message'` for message-targeted commands. |
| 21 | + |
| 22 | +The command class itself is a pure declaration with no implementation. Think of it as the schema — it defines what gets registered on Discord's side and what metadata the handler resolver uses to wire things together. |
| 23 | + |
| 24 | +<CodeTabs> |
| 25 | + <Tab label="User command" language="typescript"> |
| 26 | +```typescript filename="src/modules/user-info/commands/user-info.command.ts" |
| 27 | +import { ContextMenuCommand } from '@spraxium/common'; |
| 28 | + |
| 29 | +@ContextMenuCommand({ name: 'User Info', type: 'user' }) |
| 30 | +export class UserInfoCommand {} |
| 31 | +``` |
| 32 | + </Tab> |
| 33 | + <Tab label="Message command" language="typescript"> |
| 34 | +```typescript filename="src/modules/quotes/commands/quote.command.ts" |
| 35 | +import { ContextMenuCommand } from '@spraxium/common'; |
| 36 | + |
| 37 | +@ContextMenuCommand({ name: 'Quote', type: 'message' }) |
| 38 | +export class QuoteCommand {} |
| 39 | +``` |
| 40 | + </Tab> |
| 41 | +</CodeTabs> |
| 42 | + |
| 43 | +## Writing the handler |
| 44 | + |
| 45 | +The handler class is decorated with _@ContextMenuCommandHandler_, passing the command class as its argument. The handler must expose a `handle()` method. Use _@Ctx()_ to receive the interaction — the exact type depends on the command type. |
| 46 | + |
| 47 | +For a `'user'` command, the interaction is `UserContextMenuCommandInteraction`. It exposes `interaction.targetUser` (the `User` object) and `interaction.targetMember` (the resolved `GuildMember` if the bot is in a guild, otherwise `null`). |
| 48 | + |
| 49 | +For a `'message'` command, the interaction is `MessageContextMenuCommandInteraction`. It exposes `interaction.targetMessage` (the `Message` object). |
| 50 | + |
| 51 | +<CodeTabs> |
| 52 | + <Tab label="User handler" language="typescript"> |
| 53 | +```typescript filename="src/modules/user-info/handlers/user-info-command.handler.ts" |
| 54 | +import { ContextMenuCommandHandler, Ctx } from '@spraxium/common'; |
| 55 | +import { type UserContextMenuCommandInteraction, time } from 'discord.js'; |
| 56 | +import { UserInfoCommand } from '../commands/user-info.command'; |
| 57 | + |
| 58 | +@ContextMenuCommandHandler(UserInfoCommand) |
| 59 | +export class UserInfoHandler { |
| 60 | + async handle(@Ctx() interaction: UserContextMenuCommandInteraction): Promise<void> { |
| 61 | + const user = interaction.targetUser; |
| 62 | + const member = interaction.targetMember; |
| 63 | + |
| 64 | + const lines = [ |
| 65 | + `**${user.tag}** (\`${user.id}\`)`, |
| 66 | + `Account created: ${time(user.createdAt, 'R')}`, |
| 67 | + ]; |
| 68 | + |
| 69 | + if (member && 'joinedAt' in member && member.joinedAt) { |
| 70 | + lines.push(`Joined server: ${time(member.joinedAt, 'R')}`); |
| 71 | + } |
| 72 | + |
| 73 | + await interaction.reply({ content: lines.join('\n'), flags: 'Ephemeral' }); |
| 74 | + } |
| 75 | +} |
| 76 | +``` |
| 77 | + </Tab> |
| 78 | + <Tab label="Message handler" language="typescript"> |
| 79 | +```typescript filename="src/modules/quotes/handlers/quote-command.handler.ts" |
| 80 | +import { ContextMenuCommandHandler, Ctx } from '@spraxium/common'; |
| 81 | +import type { MessageContextMenuCommandInteraction } from 'discord.js'; |
| 82 | +import { QuoteCommand } from '../commands/quote.command'; |
| 83 | + |
| 84 | +@ContextMenuCommandHandler(QuoteCommand) |
| 85 | +export class QuoteHandler { |
| 86 | + async handle(@Ctx() interaction: MessageContextMenuCommandInteraction): Promise<void> { |
| 87 | + const message = interaction.targetMessage; |
| 88 | + const content = message.content?.trim().length |
| 89 | + ? message.content |
| 90 | + : '*(no text content)*'; |
| 91 | + |
| 92 | + const quoted = content |
| 93 | + .split('\n') |
| 94 | + .map((line) => `> ${line}`) |
| 95 | + .join('\n'); |
| 96 | + |
| 97 | + await interaction.reply({ |
| 98 | + content: `Quoting **${message.author.tag}**:\n${quoted}`, |
| 99 | + allowedMentions: { parse: [] }, |
| 100 | + }); |
| 101 | + } |
| 102 | +} |
| 103 | +``` |
| 104 | + </Tab> |
| 105 | +</CodeTabs> |
| 106 | + |
| 107 | +## Command configuration reference |
| 108 | + |
| 109 | +The _@ContextMenuCommand_ config accepts several optional fields beyond _name_ and _type_. |
| 110 | + |
| 111 | +<Table> |
| 112 | + <TableHead> |
| 113 | + <TableRow> |
| 114 | + <TableCell header>Field</TableCell> |
| 115 | + <TableCell header>Type</TableCell> |
| 116 | + <TableCell header>Required</TableCell> |
| 117 | + <TableCell header>Description</TableCell> |
| 118 | + </TableRow> |
| 119 | + </TableHead> |
| 120 | + <TableBody> |
| 121 | + <TableRow> |
| 122 | + <TableCell>*name*</TableCell> |
| 123 | + <TableCell>`string`</TableCell> |
| 124 | + <TableCell>Yes</TableCell> |
| 125 | + <TableCell>Display name shown in the Apps submenu. Up to 32 characters; spaces are allowed.</TableCell> |
| 126 | + </TableRow> |
| 127 | + <TableRow> |
| 128 | + <TableCell>*type*</TableCell> |
| 129 | + <TableCell>`'user' | 'message'`</TableCell> |
| 130 | + <TableCell>Yes</TableCell> |
| 131 | + <TableCell>Whether the command appears on user right-click or message right-click.</TableCell> |
| 132 | + </TableRow> |
| 133 | + <TableRow> |
| 134 | + <TableCell>*guild*</TableCell> |
| 135 | + <TableCell>`string`</TableCell> |
| 136 | + <TableCell>No</TableCell> |
| 137 | + <TableCell>Register as a guild command instead of globally. Propagates immediately with no Discord approval delay. Useful for testing.</TableCell> |
| 138 | + </TableRow> |
| 139 | + <TableRow> |
| 140 | + <TableCell>*defaultMemberPermissions*</TableCell> |
| 141 | + <TableCell>`bigint | number | null`</TableCell> |
| 142 | + <TableCell>No</TableCell> |
| 143 | + <TableCell>Discord permission bitfield controlling who sees the command. Server admins can override this in guild settings.</TableCell> |
| 144 | + </TableRow> |
| 145 | + <TableRow> |
| 146 | + <TableCell>*dmPermission*</TableCell> |
| 147 | + <TableCell>`boolean`</TableCell> |
| 148 | + <TableCell>No</TableCell> |
| 149 | + <TableCell>Whether the command is available in DMs. Defaults to `true`.</TableCell> |
| 150 | + </TableRow> |
| 151 | + <TableRow> |
| 152 | + <TableCell>*nsfw*</TableCell> |
| 153 | + <TableCell>`boolean`</TableCell> |
| 154 | + <TableCell>No</TableCell> |
| 155 | + <TableCell>Marks the command as NSFW. Discord hides it outside age-restricted channels for non-verified users.</TableCell> |
| 156 | + </TableRow> |
| 157 | + </TableBody> |
| 158 | +</Table> |
| 159 | + |
| 160 | +## Guards and permissions |
| 161 | + |
| 162 | +Context menu command handlers support _@UseGuards_ exactly the same way slash command handlers do. The guard pipeline runs before the handler method, and any guard that denies the interaction short-circuits execution. The exception pipeline also works identically — throwing a _SpraxiumException_ inside the handler or a guard produces a structured Discord reply. |
| 163 | + |
| 164 | +```typescript filename="src/modules/moderation/handlers/flag-message.handler.ts" |
| 165 | +import { ContextMenuCommandHandler, Ctx, UseGuards } from '@spraxium/common'; |
| 166 | +import { GuildOnlyGuard } from '@spraxium/common'; |
| 167 | +import { PermissionFlagsBits } from 'discord.js'; |
| 168 | +import type { MessageContextMenuCommandInteraction } from 'discord.js'; |
| 169 | +import { FlagMessageCommand } from '../commands/flag-message.command'; |
| 170 | + |
| 171 | +@ContextMenuCommandHandler(FlagMessageCommand) |
| 172 | +@UseGuards(GuildOnlyGuard) |
| 173 | +export class FlagMessageHandler { |
| 174 | + async handle(@Ctx() interaction: MessageContextMenuCommandInteraction): Promise<void> { |
| 175 | + const message = interaction.targetMessage; |
| 176 | + // ... moderation logic |
| 177 | + await interaction.reply({ content: 'Message flagged.', flags: 'Ephemeral' }); |
| 178 | + } |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +_@Defer_ and _@AutoDefer_ are also supported. Apply them to the handler class the same way as on slash command handlers. The defer behavior is identical — the framework defers the interaction after the guard pipeline passes. |
| 183 | + |
| 184 | +## Module registration |
| 185 | + |
| 186 | +Both the command class and the handler class must be registered in a module. Command classes go in the _commands_ array and handler classes go in the _handlers_ array. The framework reads command metadata from the _commands_ array to build the Discord registration payload, and reads handler metadata to wire up the runtime dispatcher. |
| 187 | + |
| 188 | +```typescript filename="src/modules/user-info/user-info.module.ts" |
| 189 | +import { Module } from '@spraxium/common'; |
| 190 | +import { AvatarCommand } from './commands/avatar.command'; |
| 191 | +import { UserInfoCommand } from './commands/user-info.command'; |
| 192 | +import { AvatarHandler } from './handlers/avatar-command.handler'; |
| 193 | +import { UserInfoHandler } from './handlers/user-info-command.handler'; |
| 194 | + |
| 195 | +@Module({ |
| 196 | + commands: [UserInfoCommand, AvatarCommand], |
| 197 | + handlers: [UserInfoHandler, AvatarHandler], |
| 198 | +}) |
| 199 | +export class UserContextMenuModule {} |
| 200 | +``` |
| 201 | + |
| 202 | +Context menu commands and slash commands can live in the same module with no conflicts. Both command types share the same registration phase and the same dispatcher infrastructure. You can co-locate a slash command, its handler, and a context menu command pointing at similar functionality all inside one module if that makes organizational sense for your bot. |
0 commit comments