Commands
A Command is defined once with command({ .. }). Its definition (what Discord stores) and its handler's argument types both come from that one object.
Options
import { command, defineChoices, option } from "composurejs"
const Flavor = defineChoices({ chocolate: "Chocolate chip", oat: "Oatmeal raisin" })
export const cookie = command({
name: "cookie",
description: "Give someone a cookie",
options: {
user: option.user("Who gets it"),
flavor: option.string("Flavor").choices(Flavor),
count: option.integer("How many", { min: 1, max: 12 }).optional(),
},
handler: (c, { user, flavor, count = 1 }) => `${user.displayName} gets ${count} ${flavor} cookies`,
})Option builders: option.string, integer, number, boolean, user, channel (with types), role, mentionable and attachment. Values arrive resolved:
- A
useris the user, plus their servermemberdata and adisplayName(nickname, else display name, else username). - A
channel,roleorattachmentis the object Discord resolved with the Interaction.
.optional() makes a value possibly undefined. You may list Options in any order: required ones are sent first automatically, as Discord requires.
.choices(..) restricts a value to a fixed set and narrows its type ("chocolate" | "oat" above). defineChoices takes { value: "Shown name" } for strings, or [{ name, value }] for numbers. The same Choices work for selects and radio groups.
Autocomplete
Suggest values while the user types by declaring it on the Option:
export const topping = command({
name: "topping",
description: "Pick a topping",
options: {
name: option.string("Topping").autocomplete((c, input) => TOPPINGS.filter((t) => t.startsWith(input.toLowerCase()))),
},
handler: (c, { name }) => `You picked ${name}`,
})The handler gets the partial input and the raw values of the other Options filled in so far. It may return Choices or bare values; at most 25 are sent. If it throws, the error is logged and the user sees no suggestions.
Groups
const ban = command({ name: "ban", description: "Ban a member", options: { user: option.user("Who") }, handler: (c, { user }) => `Banned ${user.username}` })
const add = command({ name: "add", description: "Add a role", options: { role: option.role("Role") }, handler: (c, { role }) => `Added ${role.name}` })
export const admin = group({
name: "admin",
description: "Admin tools",
defaultMemberPermissions: "0", // hidden from everyone but admins
subcommands: [ban, group({ name: "role", description: "Roles", subcommands: [add] })],
})This gives /admin ban and /admin role add. Groups nest one level deep, as in Discord. Settings only a top-level Command can have (contexts, integrationTypes, scope, defaultMemberPermissions, nsfw) are rejected on subcommands.
Context menu Commands
export const highFive = userCommand({ name: "High five", handler: (c, target) => `🙏 ${target.displayName}` })
export const quote = messageCommand({ name: "Quote", handler: (c, message) => `> ${message.content}` })They appear under right-click → Apps, and get the clicked user or message.
Where a Command is available
| Setting | Meaning |
|---|---|
contexts: ["guild", "botDm", "privateChannel"] | Where it can be used. |
integrationTypes: ["guildInstall", "userInstall"] | Whether it needs the bot in the server, or works wherever the user installed the app. |
scope: "dev" | Sync sends it to the guilds in COMPOSURE_SCOPE_DEV instead of globally (Deploying). |
defaultMemberPermissions | Permissions members need by default ("0": admins only). |
Localizations
Discord shows a Command's name, description, Options and Choices in the user's language when the definition carries them. localizations holds them by locale, shaped like the Command; Sync publishes them:
const Flavor = defineChoices({ choc: "Chocolate", oat: "Oatmeal" })
export const cookie = command({
name: "cookie",
description: "Give someone a cookie",
options: { user: option.user("Who gets it"), flavor: option.string("Flavor").choices(Flavor) },
localizations: {
fr: {
name: "biscuit",
description: "Donner un biscuit",
options: { user: { name: "qui", description: "Qui le reçoit" }, flavor: { name: "parfum", choices: { choc: "Chocolat", oat: "Avoine" } } },
},
},
handler: (c, { user, flavor }) => `${user.displayName} gets a ${flavor} cookie`,
})- Locales are Discord's (
"fr","en-GB","pt-BR", ...), andoptionsis keyed by the Options you defined, so a typo is a type error. Choices are keyed by value. - Localized names follow the same rules as the originals (lowercase for chat input Commands and Options) and are checked when the Command is defined.
- The handler still receives Options under their original names.
- Groups, subcommands and context menu Commands take
localizationsfor their own name (and description):group({ .., localizations: { fr: { name: "administration" } } }). - Translations kept in JSON files fit as they are:
localizations: { fr: frCookie }.
The text of your answers is yours to translate: c.locale is the user's locale.
Listing Commands on an App
export const app = new App().command(ping).route(moderation)Apps nest with .route(..), so a module can own its Commands and the root lists the module. Discord's rules are checked when a Command is defined. These include lowercase names, length limits, at most 25 Options, and choices and autocomplete not used together. Rules across the whole tree, such as a Command listed twice, are checked when the App is first built. Put app.check() in a test to catch both before deploying.
Typing c.env and c.data
Commands written on their own get an untyped c.env. To type the platform bindings, Bot Data and middleware Variables, use the factory for your App's Env:
type BotEnv = { Bindings: { GREETING: string }; Data: { emoji: string } }
export const { command, app } = createFactory<BotEnv>()
export const hello = command({
name: "hello",
description: "Hello",
handler: (c) => `${c.env.GREETING} ${c.data.emoji}`,
})