Mallard

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.

Not supported: choices (a fixed dropdown of values), autocomplete, min/max numeric bounds, channel-type filters, subcommands, or the mentionable/attachment option kinds. If you need one of those, validate inside the script body instead: e.g. read ctx.args.seconds and editResponse() an error if it's out of range (the seeded /slowmode does exactly this for its 0 to 21600 second cap).

Option types → ctx.args

Whatever the Discord option type, the value that lands in ctx.args is always a plain string. There's no native JS number, boolean, or object. ScriptArgs is typed as { [name: string]: string } for exactly this reason.

Option type What arrives in ctx.args.<name>
String the text, verbatim
Integer the number, as a string; parseInt() it
Number the number, as a string; parseFloat() it (not offered in the dashboard's Type dropdown; reachable only by hand-editing or importing a script's config)
Boolean "true" or "false" as a string; compare with === "true"
User the target user's Discord snowflake ID as a string; call getMember(id) if you need their username/roles
Channel the target channel's snowflake ID as a string; no channel-type filter is supported, any channel can be picked
Role the target role's snowflake ID as a string

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 four &quot;…: Modal&quot; triggers

SlashCommand: Modal, MessageCommand: Modal, UserCommand: Modal and ButtonClick: 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. On submit, every answer lands in ctx.interaction.values, keyed by id: ctx.interaction.values["details"].

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.

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.

Command options (TriggerConfig)
{
  "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 — 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));
}

editResponse("Banned <@" + 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 + form (TriggerConfig)
{
  "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.
var reportedUserId = ctx.args.user;
var details = ctx.interaction.values["details"];

if (!ctx.env.mod_alert_channel_id) {
    editResponse("Reporting isn't configured yet — ask a mod to set mod_alert_channel_id.");
} else {
    sendMessage(ctx.env.mod_alert_channel_id, "", [{
        title: "User report",
        description: "Reported: <@" + reportedUserId + ">\nReporter: <@" + ctx.actorId + ">\n\n" + details,
    }]);
    editResponse("Report sent to the mods. Thank you.");
}

Buttons

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.

An unhandled error has occurred. Reload 🗙