💬 Discord Responsive Interviews
This document describes the repeatable, component-driven integration process for creating responsive interactive interviews and settings dashboards in Discord. By using this standard pattern, you ensure that a Discord bot message behaves like a wizard UI: updating in-place instantly upon user interaction, saving state server-side, and preserving clean channel logs.
🧭 The Interaction Lifecycle State Machine
graph TD idle[Idle / Pending] -->|Trigger / Command| asking[Asking Q1/N] asking -->|Select Menu Pick| selected[Option Selected / Submit Enabled] asking -->|Custom Button| modalOpen[Modal Opened] modalOpen -->|Modal Submit| answered[Answer Saved] selected -->|Submit Button| answered asking -->|Skip/Chat Button| skipped[Skipped to Chat] asking -->|Cancel Button| cancelled[Cancelled] asking -->|Timeout 165s| timedOut[Timed Out / Disabled] answered -->|Next Question| asking answered -->|Final Question| complete[Complete / Confirmed] classDef active fill:#7b97aa,stroke:#333,stroke-width:2px,color:#fff; classDef terminal fill:#161618,stroke:#ed4245,stroke-width:2px,color:#fff; classDef success fill:#161618,stroke:#57f287,stroke-width:2px,color:#fff; class asking,selected active; class cancelled,timedOut,skipped terminal; class complete success;
⚡ The Golden Rule: 3-Second ACK Limit
Discord requires all component interactions (clicks, select choices, modal submissions) to be acknowledged within 3 seconds. If this window is missed, the user receives an ugly "Application did not respond" error.
The Correct Sequence (De-coupled Processing)
- Acknowledge Instantly: Invoke
interaction.deferUpdate()orinteraction.showModal()immediately. - Execute Work: Perform state updates, network calls, or launch background processes during the 15-minute post-ACK token lifespan.
- Evolve In-Place: Mutate the message via
interaction.message.edit()with the updated components and embed.
// ✅ CORRECT: Immediate ACK, then run heavy processes
client.on("interactionCreate", async (interaction) => {
if (!interaction.isButton()) return
// 1. Acknowledge the interaction immediately to protect the token
await interaction.deferUpdate()
// 2. Perform state updates or expensive calls
const state = await updateSessionState(interaction.customId)
// 3. Edit the message with the new UI state
await interaction.message.edit(renderQuestion(state))
})
// ❌ WRONG: Processing before ACK (fails if saveSession takes >3s)
client.on("interactionCreate", async (interaction) => {
if (!interaction.isButton()) return
await saveSessionToDatabase() // May stall or timeout
await interaction.update(renderQuestion()) // Token might already be dead!
})📋 The Repetable Integration Recipe
Follow this step-by-step checklist to build any interactive questionnaire or configuration interface.
1. Structure the Session State (Server-Side)
Never serialize large objects, JSON, prompts, or sensitive details inside component customIds (limit is 100 characters). Instead, keep state server-side and store a short lookup key.
const session = {
id: "short-random-id", // e.g. "x7g9"
threadId: "123456789012345678",
messageId: "987654321098765432",
userId: "481482311288750080",
index: 0, // Current question index
status: "asking", // asking | confirming | complete | cancelled | timed_out
answers: {}, // Collected key-value answers
startedAt: Date.now(),
}2. Design the Custom IDs
Use tiny, colon-delimited, namespace-routed routing strings for customId:
iv:<sessionId>:sel:<questionId> # Select menu interactions
iv:<sessionId>:submit:<questionId> # Multi-select commit button
iv:<sessionId>:custom:<questionId> # Open text modal
iv:<sessionId>:back # Navigate to previous question
iv:<sessionId>:cancel # Cancel interview3. Lay Out the Components
Discord messages allow up to 5 Action Rows, with each row holding either 1 Select Menu OR up to 5 Buttons.
- Buttons (<= 5 per row): Best for 2–4 static options (e.g. Yes/No/Cancel).
- Select Menu (1 per row): Best for 5–25 choices. Enable multi-select by setting
minValuesandmaxValues > 1(cap options at 25). - Modals: Triggered via
interaction.showModal(). Used to collect freeform text input. Note thatshowModal()must be the very first response to the interaction.
Button Color Standards
Success(Green):Submit,Confirm,CompleteDanger(Red):Cancel,Stop,ResetPrimary(Blurple): Default path, recommended optionSecondary(Gray): Neutral options (Back,Skip,Custom…)Link(URL): Direct navigation to external links (does not trigger an interaction event)
4. Interactive Pinning & Log Cleanup
To keep the interview sticky and easy to find, pin the active message to the thread. However, Discord automatically posts a system message stating "[User] pinned a message to this channel." to clutter the thread.
You must clean this up programmatically:
// Pin the interactive question
await msg.pin().catch(() => {})
// Clean up the automatic pin notice
const messages = await channel.messages.fetch({ limit: 5 }).catch(() => null)
if (messages) {
const pinNotice = messages.find((m) => m.type === 6) // Type 6 is Channel Pin Message
if (pinNotice) await pinNotice.delete().catch(() => {})
}🧬 Golden Integration Skeleton (discord.js v14)
Use this boilerplate skeleton when writing responsive component collectors.
import {
Client,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
StringSelectMenuBuilder,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
MessageFlags,
} from "discord.js"
client.on("interactionCreate", async (interaction) => {
// 1. Filter out unrelated interactions
if (!interaction.customId?.startsWith("iv:")) return
// 2. Gate with an allow-list
if (!isAllowedUser(interaction.user.id)) {
return interaction.reply({
content: "⛔ You are not authorized to interact with this session.",
flags: MessageFlags.Ephemeral,
})
}
// 3. Parse Custom ID structure
const [prefix, sessionId, action, questionId] = interaction.customId.split(":")
const state = getSessionState(sessionId)
if (!state || state.status !== "asking") {
return interaction.reply({
content: "⌛ This interaction has expired or the session is no longer active.",
flags: MessageFlags.Ephemeral,
})
}
// 4. Handle Modals (must NOT deferUpdate first!)
if (action === "custom") {
const modal = new ModalBuilder()
.setCustomId(`iv:${sessionId}:modal:${questionId}`)
.setTitle("Provide Custom Input")
const input = new TextInputBuilder()
.setCustomId("val")
.setLabel("Enter your answer")
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
modal.addComponents(new ActionRowBuilder().addComponents(input))
await interaction.showModal(modal)
// Wait for the modal submission
const submit = await interaction
.awaitModalSubmit({
time: 120000,
filter: (m) =>
m.customId === `iv:${sessionId}:modal:${questionId}` && m.user.id === interaction.user.id,
})
.catch(() => null)
if (submit) {
await submit.deferUpdate()
state.answers[questionId] = submit.fields.getTextInputValue("val")
await advanceQuestion(state)
await interaction.message.edit(renderQuestion(state))
}
return
}
// 5. Handle standard component clicks (buttons/select menus)
await interaction.deferUpdate() // PROTECT TOKEN INSTANTLY
if (interaction.isStringSelectMenu() && action === "sel") {
// Save selection temporarily
state.tempSelects = interaction.values
// Enable/disable submit button or update preview fields
await interaction.message.edit(renderQuestion(state))
} else if (action === "submit") {
// Commit multi-select answers
state.answers[questionId] = state.tempSelects
await advanceQuestion(state)
await interaction.message.edit(renderQuestion(state))
} else if (action === "cancel") {
state.status = "cancelled"
// Strip components to freeze UI and prevent stale clicks
await interaction.message.edit({
embeds: [renderCancelledEmbed(state)],
components: [],
})
await cleanupSession(state)
}
})📏 Hard Discord API Limits Reference
To avoid silent payload rejections or gateway disconnects, memorize these structural constraints:
| Scope | Limit Constraint | Failure Consequence |
|---|---|---|
| Initial Interaction ACK | 3 seconds | Gateway token expires; user sees “Application did not respond”. |
| Interaction Lifespan | 15 minutes | Token invalidates; cannot edit/respond to message. |
| Message Content | 2000 characters | Payload rejected. Slice content at ~1900 chars. |
| Action Rows | 5 rows | Command throws HTTP 400 Bad Request. |
| Buttons per Row | 5 buttons | Command throws HTTP 400 Bad Request. |
| Select Options | 25 options | Command throws HTTP 400 Bad Request. |
| Button Labels | 80 characters | Truncated in UI. Aim for 35 characters for readability. |
| Select Placeholder | 150 characters | Command throws HTTP 400 Bad Request. |
| Embed Title | 256 characters | Truncated or rejected. |
| Embed Description | 4096 characters | Rejected. Always truncate log blocks at ~3800. |
| Embed Field Value | 1024 characters | Rejected. Truncate nested tables/code blocks at ~950. |
| Total Embed Size | 6000 characters (cumulative) | Whole message fails to post. |
🧯 Failure Mitigation Guide
Symptom: Stale controls are clicked after the interview completes
- Fix: Always strip components when completing, cancelling, or timing out. Set
components: []in the final message edit. - Fallback: Check
state.statusinside theinteractionCreatehandler; if it is not"asking", immediately respond with an ephemeral message:"⌛ This interview has expired."
Symptom: Multi-select menus don’t have a commit hook
- Fix: For multi-select grids (
max_values > 1), never transition immediately upon choice. The select menu updates the temporary state, and a separate “Submit” button in a bottom Action Row is enabled to let the user finalize their choices.
Symptom: Ephemeral modals fail to open
- Fix: Modals cannot be shown if
deferUpdate()ordeferReply()was already called on that interaction. Make sureinteraction.showModal()is the very first API response to the component click.