Commands & modals
How a slash command's options, a context-menu command, and a popup form each turn into values
your script can read. This is the piece that connects the dashboard's editor UI to
ctx.args and ctx.interaction.values.
Slash command options
On the dashboard's script editor, a SlashCommand (or
SlashCommand: Modal) script has a "Command Options" section: each row has
Name (the helper text warns "no hyphens: used as
ctx.args.name in scripts", since it becomes a JS identifier),
Type, Description, and a Req switch.
That editor UI is a form over one JSON object stored on the script:
{
"options": [
{ "name": "user", "type": "User", "description": "The user to ban", "required": true },
{ "name": "reason", "type": "String", "description": "Reason for the ban", "required": true },
{ "name": "duration_min", "type": "Integer", "description": "Duration in minutes (0 = permanent)", "required": false },
{ "name": "delete_message_days", "type": "Integer", "description": "Days of messages to delete", "required": false }
]
}
Discord enforces a 32-character name limit (lowercase, no spaces; the editor replaces hyphens with underscores as you type) and a 100-character description limit, and caps a single command at 25 options. Required options are automatically reordered before optional ones at registration time, regardless of the order you added them in. Discord requires that ordering, but the editor doesn't enforce it as you type.
Each option's More settings adds what Discord itself enforces before
the command even reaches Mallard: choices (a fixed list of
{ name, value }, up to 25), minValue/maxValue on
integer and number options, minLength/maxLength on strings, and
channelTypes to limit a channel option to, say, voice channels. Option
types are string, integer, number, boolean, user, channel, role, mentionable (a user or
a role) and attachment.
Autocomplete. An option with more than 25 choices, or an
autocompleteKey naming a key-value entry that holds a JSON array (of strings
or { name, value }), suggests matches as the user types. Mallard answers the
suggestions itself from that list, fuzzy-matched, without running your script, which is
the only way to answer inside Discord's 3-second window. Keep the list current from any
script with setKV.
Subcommands. Name a script with spaces to make it a subcommand:
role add and role remove register as one /role
command with two subcommands, and config channel set adds a subcommand
group. Each subcommand is its own script with its own options, Ephemeral switch and
required permissions (checked when it's invoked, since Discord only knows one permission
setting per top-level command). ctx.commandPath is the full name and
ctx.subcommand its last word. A plain role script and
role … subcommands can't coexist.
Option types → ctx.args
Whatever the Discord option type, the value that lands in ctx.args is
always a plain string, as it always has been. ctx.options
holds the same values typed (integer and number options as numbers, booleans as booleans),
and ctx.resolved holds the users, members, roles, channels and attachments
the options point at, so a user option needs no getMember() call.
| Option type | What arrives in ctx.args.<name> |
|---|---|
| String | the text, verbatim |
| Integer | the number, as a string (ctx.options has it as a number) |
| Number | the number, as a string (ctx.options has it as a number) |
| Boolean | "true" or "false" as a string (ctx.options has it as a boolean) |
| User | the target user's Discord snowflake ID as a string; their profile and member data are in ctx.resolved.users/members |
| Channel | the target channel's snowflake ID as a string (limit the kinds with channelTypes); detail in ctx.resolved.channels |
| Role | the target role's snowflake ID as a string; detail in ctx.resolved.roles |
| Mentionable | a user's or a role's snowflake ID as a string; look it up in ctx.resolved to tell which |
| Attachment | the uploaded file's attachment ID; its url, name and size are in ctx.resolved.attachments |
Message & user context-menu commands
MessageCommand and UserCommand (right-click a message or a
user → Apps) have no options at all; there's nothing to configure beyond the command's
own Name and Description. Unlike slash commands,
their names can include spaces and capital letters, and are matched verbatim rather
than lowercased (the seeded examples are literally named Report Message
and View User Notes). The right-clicked target arrives as
ctx.message (MessageCommand) or ctx.member (UserCommand,
where ctx.actorId is the invoking moderator, not the target). See
the ctx object
.
The "…: Modal" triggers
SlashCommand: Modal, MessageCommand: Modal,
UserCommand: Modal, ButtonClick: Modal and
SelectMenu: Modal each open a popup form immediately. Discord requires a modal to be the interaction's first
response, so the script's own body doesn't run until the form is submitted. On the
dashboard, this is the script's "Form (Modal) Definition" section: a JSON object with
title and up to 5 inputs:
{
"title": "Report this message",
"inputs": [
{ "id": "details", "label": "What's wrong with it?", "style": "paragraph", "required": true }
]
}
Each input has id (the key it's read back under), label,
style ("short" for a single line or
"paragraph" for multi-line; short is the default), required
(defaults to true, unlike command options which default to
false), and an optional placeholder, description,
minLength/maxLength, and value to prefill it.
A prefill can use {{args.name}} (a command option), {{payload}},
{{message.content}}, {{member.displayName}} or
{{kv:key}}, so an "edit your application" form opens with the saved answer.
On submit, every answer lands in ctx.interaction.values, keyed by
id: ctx.interaction.values["details"].
An input's type can also be select (with options
of { label, value, description?, emoji?, default? }),
userSelect, roleSelect, channelSelect,
mentionableSelect, checkbox, checkboxGroup,
radio, file (uploads, with maxValues and
fileTypes), or textDisplay (a block of text, with
text instead of an id and label). Selects and checkbox groups answer with an
array, a checkbox with "true"/"false", and uploaded files land in
ctx.interaction.files[id].
SlashCommand: Modal is the one case where a script's config carries both
shapes at once. The registrar reads options (to build the
slash command) and the modal builder reads title/inputs (to
build the form) from the same JSON object, each ignoring what it doesn't recognize. So
on submit, the script sees both the command's own options in
ctx.args and the form's answers in
ctx.interaction.values. The worked example below shows this combination.
ButtonClick: Modal is the odd one out: it has no command options at all
(a button has no slash-command surface), so only title/inputs
apply. The button's own payload, set by whichever script sent it via the
sendMessage/editResponse button array, is still available as
ctx.interaction.payload alongside the form's answers.
Filters in the same config
A script's trigger filters (see
Triggers
) are a third member of that same
object, written by the editor's Filters card. The registrar reads
options, the modal builder reads title/inputs,
and the filter evaluator reads filters; each ignores the rest. You only
need this shape if you're hand-editing a config or a backup file:
{
"options": [ … ],
"filters": {
"channels": { "mode": "allow", "ids": ["112233445566778899"] },
"roles": { "mode": "deny", "ids": ["998877665544332211"] },
"users": { "mode": "deny", "ids": ["445566778899001122"] },
"emoji": { "mode": "allow", "values": ["⭐", "starboard"] },
"auditActions": { "mode": "allow", "values": ["MemberBanAdd", "MemberKick"] },
"content": { "mode": "matches", "regex": "^!ticket\\b", "ignoreCase": true },
"allowBots": false,
"attachments": "any",
"cooldown": { "seconds": 30, "scope": "script" }
}
}
Every member is optional, and one with an empty list filters nothing.
mode is "allow" or "deny";
content.mode is "matches" or "notMatches", and
takes either a regex or a contains array of phrases;
attachments is "any", "required" or
"none"; cooldown.scope is "script" or
"user". A filter naming something the trigger doesn't carry is ignored
rather than blocking, but a filters member that can't be read at all
stops the script running, so a hand-edited config is checked when you save it.
Worked example: /ban
Adapted from the seeded /ban command (the mod-role permission guard it's
normally wrapped in is omitted here for brevity). Shows one required User
option, one required String, and two optional Integer
options, with the parseInt() conversion and the
ctx.args.reason || "No reason provided" fallback idiom used throughout
the seeded scripts.
{
"options": [
{ "name": "user", "type": "User", "description": "The user to ban", "required": true },
{ "name": "reason", "type": "String", "description": "Reason for the ban", "required": true },
{ "name": "duration_min", "type": "Integer", "description": "Duration in minutes (0 = permanent)", "required": false },
{ "name": "delete_message_days", "type": "Integer", "description": "Days of messages to delete", "required": false }
]
}
Script body
var userId = ctx.args.user;
var reason = ctx.args.reason || "No reason provided";
var durationMin = ctx.args.duration_min ? parseInt(ctx.args.duration_min) : 0;
var deleteDays = ctx.args.delete_message_days ? parseInt(ctx.args.delete_message_days) : 0;
var isPermanent = durationMin <= 0;
var target = getMember(userId);
var targetName = target ? target.username : userId;
// DM before the ban: afterwards there's no mutual guild left to DM through.
// false means their DMs are closed, and the ban still goes ahead.
var dmSent = sendDm(userId, "", [{
title: isPermanent ? "Permanent Ban" : "Temporary Ban",
description: "Reason: " + reason,
}]);
ban(userId, reason, deleteDays);
if (!isPermanent) {
// Actions can't be delayed, so log the unban due time to the KV store;
// a Scheduled script sweeps due entries on its next run.
var pending = JSON.parse(getKV("mod:pending_unbans") || "[]");
pending.push({ userId: userId, unbanAt: Date.now() + durationMin * 60000, reason: reason });
setKV("mod:pending_unbans", JSON.stringify(pending));
}
var target = getMember(userId);
var targetName = target ? target.username : userId;
editResponse("Banned " + targetName + " (" + userId + "). Reason: " + reason
+ (dmSent ? "" : " (could not DM them the appeal link)"));Worked example: /report
The seeded SlashCommand: Modal script. One command option
(user) plus a one-field form. The script reads ctx.args.user
and ctx.interaction.values["details"] together on submit.
{
"options": [
{ "name": "user", "type": "User", "description": "Who are you reporting?", "required": true }
],
"title": "Report a user",
"inputs": [
{ "id": "details", "label": "What happened?", "style": "paragraph", "required": true }
]
}
Script body
// Runs when the form is submitted: both the command's own option and the
// form's answer are available together.
if (!ctx.env.mod_alert_channel_id) {
editResponse("Reporting isn't configured yet. Ask a mod to set mod_alert_channel_id.");
} else {
var actor = getMember(ctx.actorId);
var actorName = actor ? actor.username : ctx.actorId;
sendEmbed(ctx.env.mod_alert_channel_id, "User report",
`Reported: ${ctx.args.user}\nReporter: ${actorName} (${ctx.actorId})\n\n${ctx.interaction.values.details}`);
editResponse("Report sent to the mods. Thank you.");
}Buttons and select menus
ButtonClick and ButtonClick: Modal carry no options or form
config of their own for the plain case; a button's data comes from whichever script
sent it, via the buttons array on
sendMessage/sendDm/editResponse/editMessage:
each button is { label, style, payload, handler }, where
handler names the script that runs on click and payload
(max 50 chars) comes back to it as ctx.interaction.payload. Whether the
click replies immediately or opens a form is decided by the handler script's own
trigger type, not by anything set on the button. See
sendMessage
for the full
button shape. A button with a url is a link button instead (no handler),
and any button can carry an emoji.
Select menus work the same way through selects on a
MessageOptions object: sendMessage(channelId, { content, selects: [{ handler,
options: [...] }] }). A SelectMenu script runs with the picks in
ctx.interaction.selected; user, role and channel menus pick server entities
and resolve them in ctx.resolved. That's how a role picker is built.
Replying vs. updating. By default a click gets its own reply (the
"thinking…" message editResponse() fills in). Turn on "Update the clicked
message" on a ButtonClick or SelectMenu script and the click is
acknowledged silently instead, and editResponse() rewrites the message the
button sits on: pagination, toggles and counters without a trail of replies. The clicked
message is already in ctx.interaction.message, and followUp()
sends anything else, public or ephemeral.