💬 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)

  1. Acknowledge Instantly: Invoke interaction.deferUpdate() or interaction.showModal() immediately.
  2. Execute Work: Perform state updates, network calls, or launch background processes during the 15-minute post-ACK token lifespan.
  3. 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 interview

3. 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 minValues and maxValues > 1 (cap options at 25).
  • Modals: Triggered via interaction.showModal(). Used to collect freeform text input. Note that showModal() must be the very first response to the interaction.

Button Color Standards

  • Success (Green): Submit, Confirm, Complete
  • Danger (Red): Cancel, Stop, Reset
  • Primary (Blurple): Default path, recommended option
  • Secondary (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:

ScopeLimit ConstraintFailure Consequence
Initial Interaction ACK 3 secondsGateway token expires; user sees “Application did not respond”.
Interaction Lifespan 15 minutesToken invalidates; cannot edit/respond to message.
Message Content 2000 charactersPayload rejected. Slice content at ~1900 chars.
Action Rows 5 rowsCommand throws HTTP 400 Bad Request.
Buttons per Row 5 buttonsCommand throws HTTP 400 Bad Request.
Select Options 25 optionsCommand throws HTTP 400 Bad Request.
Button Labels 80 charactersTruncated in UI. Aim for 35 characters for readability.
Select Placeholder 150 charactersCommand throws HTTP 400 Bad Request.
Embed Title 256 charactersTruncated or rejected.
Embed Description 4096 charactersRejected. Always truncate log blocks at ~3800.
Embed Field Value 1024 charactersRejected. 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.status inside the interactionCreate handler; 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() or deferReply() was already called on that interaction. Make sure interaction.showModal() is the very first API response to the component click.

Ask Docs

AI assistant to help answer questions about the documentation. Answers are read-only and cite docs/source.

Hi! How can I help you with the documentation today? Answers are read-only and cite docs/source.

Ctrl+Enter to send