Commands and components
Slash commands, right-click commands, buttons, select menus and forms work as on Discord:
the same endpoints for registering commands, the same INTERACTION_CREATE
event, the same callback types and follow-ups. discord.js and discord.py handle them with
their usual APIs.
Registering commands
A bot has commands everywhere it is (global) and commands for one guild. Both are made with Discord's endpoints, using the bot's token:
| Endpoint | Notes |
|---|---|
GET /applications/{application}/commands | The global commands. |
POST /applications/{application}/commands | Makes one (201), or replaces the one with the same type and name (200, same id). |
PUT /applications/{application}/commands | Bulk overwrite: the commands become exactly the list given. Commands with the same type and name keep their ids. This is what discord.js's commands.set() and discord.py's tree.sync() use. |
GET PATCH DELETE /applications/{application}/commands/{command} | One command. |
/applications/{application}/guilds/{guild}/commands… | The same, for one guild the bot is in. |
- Types:
1slash commands,2user commands and3message commands (right-click). Up to 100 slash, 5 user and 5 message commands per application, globally and per guild. - Discord's rules for names (lower case for slash commands, up to 32 characters), descriptions (1–100; none for user and message commands) and options: up to 25, required ones first, names unique, 8000 characters in all.
- Option types 1–11: subcommands and subcommand groups (not mixed with other options), strings, integers, booleans, users, channels, roles, mentionables, numbers and attachments. Choices (up to 25) or
autocomplete,min_value/max_value,min_length/max_lengthandchannel_types. default_member_permissions: a person needs all those permissions in the channel to see and use the command;"0"means administrators only.nsfw: truecommands only show in NSFW channels.- Localised names and descriptions are accepted and given back, but people see the plain ones.
- A guild command hides a global one with the same type and name in that guild. Per-command permission overrides (Discord's Integrations settings) don't exist:
GET …/guilds/{guild}/commands/permissionsis always empty.
People see commands in guild text channels, voice channels' chats and threads only. Slash commands come up when
they type / in the message box. User and message commands are under
Apps when they right-click (or long-press) someone or a message. Everyone
needs Send Messages in the channel to use any command. A bot's commands go away when it
leaves the guild.
Receiving interactions
When someone uses a command, clicks a button, picks from a select, types in an
autocomplete option or submits a form, the bot gets INTERACTION_CREATE over
the gateway. No intent is needed. If the bot has several sessions, one of them gets it.
Voicaw has no HTTP interactions endpoint: interactions only come over the gateway, so a
bot must be connected to receive them.
| Type | What | data |
|---|---|---|
2 Application command | A command was used | id, name, type, options (subcommands nested, as on Discord), target_id for user and message commands, resolved (users, members, roles, channels, messages, attachments named in it) |
3 Message component | A button or select on the bot's message | custom_id, component_type, values and resolved for selects. The interaction's message is the message. |
4 Autocomplete | Someone is typing in an autocomplete option | As type 2, with the option being typed marked focused (its value is whatever is typed so far) |
5 Modal submit | A form the bot opened was submitted | custom_id and components: the form's structure with each text input's value and each select's values |
The interaction also has id, token, application_id,
guild_id, guild, channel_id, channel,
member (with their permissions in the channel),
app_permissions (the bot's), locale (en-GB),
context: 0 and attachment_size_limit. There's never a
user field on its own: interactions always come from a guild.
Voicaw checks everything before the bot hears of it: option values against the command (types, choices, limits, and only users, roles and channels the person can see), select values against the select, and forms against the form that was opened. Disabled components can't be used.
Responding
Answer once, within 3 seconds, at
POST /interactions/{interaction.id}/{interaction.token}/callback (no bot token
needed). If the bot doesn't answer in time, or isn't connected, the person sees "The
application did not respond" and the interaction is gone.
| Type | Does | Allowed for |
|---|---|---|
4 Channel message | Replies with a message (data: content, embeds, files, components, allowed_mentions, flags) | Commands, components, form submits |
5 Deferred channel message | Shows "<bot> is thinking…" until you follow up (data.flags: 64 for an ephemeral one) | Commands, components, form submits |
6 Deferred update | Acknowledges without showing anything | Components, form submits |
7 Update message | Edits the message the component is on | Components, form submits from a component |
8 Autocomplete result | data.choices: up to 25 { name, value } | Autocomplete |
9 Modal | Opens a form (see Forms) | Commands, components |
- Files go as
multipart/form-data, with the callback's JSON ({ type, data }) inpayload_json. ?with_response=trueanswers with{ interaction, resource }, the message included, instead of204.- Answering twice fails with
40060; too late, or an unknown interaction, with10062. A type that isn't allowed for that interaction fails with50035. - Replies show "<person> used /<command>" above them, and carry
interaction_metadata(and the olderinteraction) in the API. Replies to commands have Discord's message types20(slash) and23(user and message commands).
Ephemeral replies
With flags: 64, a reply or follow-up is only for the person who used the
interaction: "Only you can see this". As on Discord, it is never stored for the channel. It
goes only to that person's open app, which keeps it until they reload or dismiss it. The bot
can edit or delete it, and its components work, for 15 minutes. It is also lost if
Voicaw's API restarts in that time (the bot then gets 10008 Unknown Message).
Deferring and follow-ups
For 15 minutes after the interaction, its token works as a webhook at
/webhooks/{application.id}/{interaction.token} (no bot token needed), once the
bot has answered:
| Endpoint | Notes |
|---|---|
POST /webhooks/{application}/{token} | A follow-up message (flags: 64 for ephemeral). After a deferred reply (type 5), the first follow-up fills the "thinking…" message instead. |
GET PATCH DELETE /webhooks/{application}/{token}/messages/@original | The reply. Editing a deferred reply fills it (discord.js's editReply). |
GET PATCH DELETE /webhooks/{application}/{token}/messages/{message} | A follow-up. |
Components
Bots can put components on any message they send or edit (components in the
body), on replies and on follow-ups. Webhooks' components are ignored.
- Up to 5 action rows (
type: 1), each holding up to 5 buttons or one select. - Buttons (
type: 2): styles 1 primary, 2 secondary, 3 success, 4 danger and 5 link (with aurl, http or https, and nocustom_id). A label (up to 80 characters), an emoji, or both.disabled. Premium buttons (style 6) aren't supported. - Selects: string (
3, up to 25optionswith label, value, description, emoji anddefault), user (5), role (6), mentionable (7) and channel (8, withchannel_types).placeholder,min_valuesandmax_values(0–25),default_values,disabled. custom_id: 1–100 characters, unique in the message.- Discord's newer layout components (the
IS_COMPONENTS_V2flag: sections, containers, text displays…) aren't supported. Sending them fails with50035.
Forms
A form (callback type 9) has a custom_id, a title (up to 45
characters) and 1–5 fields:
- An action row holding one text input (
type: 4):label,style1 (short) or 2 (paragraph),min_length/max_length(up to 4000),required,valueandplaceholder. - A label component (
type: 18, withlabelanddescription) holding a text input or a string select.
A form can be submitted once, within 15 minutes, by the person it was opened for. Its answers are checked against it (required fields, lengths, select options) before the bot gets the submit.
discord.js example
Checked with discord.js 14.27, set up as in discord.js, discord.py and HTTP:
import {
Client, GatewayIntentBits, Events, SlashCommandBuilder, ActionRowBuilder, ButtonBuilder,
ButtonStyle, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags,
} from 'discord.js';
const client = new Client({
intents: [GatewayIntentBits.Guilds],
rest: { api: 'https://api.voicaw.com/api', cdn: 'https://api.voicaw.com/api/cdn' },
});
client.once(Events.ClientReady, async () => {
await client.application.commands.set([
new SlashCommandBuilder().setName('ping').setDescription('Ping'),
new SlashCommandBuilder().setName('menu').setDescription('Buttons and a select'),
new SlashCommandBuilder().setName('feedback').setDescription('Opens a form'),
new SlashCommandBuilder().setName('slow').setDescription('Thinks first'),
new SlashCommandBuilder().setName('city').setDescription('Pick a city')
.addStringOption((o) => o.setName('name').setDescription('City').setRequired(true).setAutocomplete(true)),
]);
});
client.on(Events.InteractionCreate, async (i) => {
if (i.isAutocomplete()) {
return i.respond([{ name: 'London', value: 'london' }, { name: 'Lisbon', value: 'lisbon' }]);
}
if (i.isChatInputCommand()) {
if (i.commandName === 'ping') return i.reply({ content: 'Pong!', flags: MessageFlags.Ephemeral });
if (i.commandName === 'menu') {
return i.reply({
content: 'Choose',
components: [
new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('yes').setLabel('Yes').setStyle(ButtonStyle.Success),
new ButtonBuilder().setLabel('Docs').setURL('https://voicaw.com').setStyle(ButtonStyle.Link),
),
new ActionRowBuilder().addComponents(
new StringSelectMenuBuilder().setCustomId('fruit')
.addOptions({ label: 'Apple', value: 'apple' }, { label: 'Pear', value: 'pear' }),
),
],
});
}
if (i.commandName === 'feedback') {
return i.showModal(new ModalBuilder().setCustomId('fb').setTitle('Feedback').addComponents(
new ActionRowBuilder().addComponents(
new TextInputBuilder().setCustomId('text').setLabel('Thoughts').setStyle(TextInputStyle.Paragraph),
),
));
}
if (i.commandName === 'slow') {
await i.deferReply();
await i.editReply('Done thinking');
return i.followUp({ content: 'Only you see this', flags: MessageFlags.Ephemeral });
}
if (i.commandName === 'city') return i.reply(`You chose ${i.options.getString('name')}`);
}
if (i.isButton()) return i.update({ content: 'You said yes', components: [] });
if (i.isStringSelectMenu()) return i.reply({ content: `Picked ${i.values.join(', ')}`, flags: MessageFlags.Ephemeral });
if (i.isModalSubmit()) return i.reply(`Thanks for "${i.fields.getTextInputValue('text')}"`);
});
client.login(process.env.VOICAW_TOKEN);
discord.py example
Checked with discord.py 2.7.1. Set the three addresses first, as in discord.js, discord.py and HTTP (the gateway line matters: without it, discord.py sends your token to Discord).
import os
import discord, yarl
from discord import app_commands
from discord.http import Route
Route.BASE = 'https://api.voicaw.com/api/v10'
discord.gateway.DiscordWebSocket.DEFAULT_GATEWAY = yarl.URL('wss://api.voicaw.com/api/gateway/')
discord.asset.Asset.BASE = 'https://api.voicaw.com/api/cdn'
GUILD = discord.Object(id=int(os.environ['VOICAW_GUILD_ID']))
class Yes(discord.ui.View):
@discord.ui.button(label='Yes', style=discord.ButtonStyle.success, custom_id='yes')
async def yes(self, interaction, button):
await interaction.response.edit_message(content='You said yes', view=None)
class Feedback(discord.ui.Modal, title='Feedback'):
thoughts = discord.ui.TextInput(label='Thoughts', custom_id='thoughts')
async def on_submit(self, interaction):
await interaction.response.send_message(f'Thanks for "{self.thoughts.value}"')
client = discord.Client(intents=discord.Intents.default())
tree = app_commands.CommandTree(client)
@tree.command(name='hello', description='Says hello', guild=GUILD)
async def hello(interaction, name: str):
await interaction.response.send_message(f'Hello {name}', view=Yes())
@tree.command(name='feedback', description='Opens a form', guild=GUILD)
async def feedback(interaction):
await interaction.response.send_modal(Feedback())
@client.event
async def on_ready():
await tree.sync(guild=GUILD)
client.run(os.environ['VOICAW_TOKEN'])