喂!我是 Wei

Front-End Engineer

Be a Problem Solver.

⌘K

導覽

所有文章緣起互動小功能

文章分類

目錄
概念:StringSelectMenu vs Button建立靜態 StringSelectMenuisStringSelectMenu():接收選單事件動態選項:從外部資料建立多選模式和 Button 搭配:兩段式互動完整流程總覽完整骨架總結

相關文章

Discord Bot 監控與告警:Bot 掛掉時自動發通知到頻道

2026年5月18日

Discord Bot 串接 Groq:打造高速 AI 對話助理

2026年4月1日

Discord Bot 排程任務:node-cron 做每日公告與資料重置

2026年3月31日

最新文章
全部 →
前端 CI/CD 與正式環境除錯:從 Pull Request 到事故排查
2026-06-24
即時資料怎麼選?Polling、SSE、WebSocket 比較
2026-06-23
前端系統設計:如何拆元件、資料流與大型專案架構?
2026-06-22
無障礙不是加 ARIA:語意化 HTML、鍵盤操作與焦點管理
2026-06-21
CSS 與 RWD 面試整理:Flexbox、Grid、定位與層疊脈絡
2026-06-19
← 返回文章列表

Discord Bot 下拉選單:StringSelectMenu 完整設計

2026年3月25日·約 8 分鐘閱讀·
Discord.jsBotSelectMenuNode.js

上一篇的 Button 處理單一確認操作,但如果選項有好幾種,逐一放按鈕就佔版面了。這時候換下拉選單(StringSelectMenu)更適合。


概念:StringSelectMenu vs Button

ButtonStringSelectMenu
適合場景少量固定選項(確認 / 取消 / 2–3 個動作)多選項(分類、角色、設定值…)
最多幾個一列 5 個,最多 5 列每個選單最多 25 個選項
互動方式點擊馬上觸發從列表選擇後觸發
佔版面每個獨立按鈕都佔一格一個選單折疊成一行

實際上兩者常一起用:先用 Select Menu 讓使用者選「做什麼」,再根據選擇回覆帶 Button 的確認訊息。


建立靜態 StringSelectMenu

discord.js v14 用 StringSelectMenuBuilder 搭配 StringSelectMenuOptionBuilder 組合選單,再放進 ActionRowBuilder 送出:

index.js
import {
  ActionRowBuilder,
  StringSelectMenuBuilder,
  StringSelectMenuOptionBuilder,
} from "discord.js";
 
// Slash Command handler:/search 指令觸發時呼叫
async function handleSearch(interaction) {
  const row = new ActionRowBuilder().addComponents(
    new StringSelectMenuBuilder()
      .setCustomId("select_category")
      .setPlaceholder("選擇分類")
      .addOptions(
        new StringSelectMenuOptionBuilder()
          .setLabel("後端")
          .setValue("後端")
          .setDescription("Node.js、Go、Python…"),
        new StringSelectMenuOptionBuilder()
          .setLabel("前端")
          .setValue("前端")
          .setDescription("React、Vue、CSS…"),
        new StringSelectMenuOptionBuilder()
          .setLabel("DevOps")
          .setValue("DevOps")
          .setDescription("Docker、CI/CD、K8s…")
      )
  );
 
  await interaction.reply({
    content: "請選擇分類:",
    components: [row],
    flags: 64, // ephemeral,只有本人看到
  });
}

setCustomId 是這個選單的識別碼,稍後在 interactionCreate 裡用它來對應到正確的 handler。setPlaceholder 是選單折疊時顯示的提示文字。

/search 指令觸發後,選單訊息折疊顯示「選擇分類」

點擊後展開,每個選項連同灰字說明一起顯示:

選單展開後三個選項(後端 / 前端 / DevOps)連同說明

每個 option 有三個欄位:

  • setLabel() — 使用者看到的文字(必填)
  • setValue() — 程式拿到的值(必填,要唯一)
  • setDescription() — 灰字說明(選填)
  • setEmoji() — 可以加 emoji(選填)

isStringSelectMenu():接收選單事件

使用者選完後,interactionCreate 觸發,用 interaction.isStringSelectMenu() 判斷:

index.js
client.on("interactionCreate", async (interaction) => {
 
  if (interaction.isStringSelectMenu()) {
    const { customId } = interaction;
 
    if (customId === "select_category") {
      return handleCategorySelect(interaction);
    }
    return;
  }
 
  // ...
});

handler 裡用 interaction.values 取得使用者選的值(陣列,因為可以開啟多選):

async function handleCategorySelect(interaction) {
  const [category] = interaction.values; // 單選取第一個
 
  await interaction.update({
    content: `你選了「${category}」,正在處理...`,
    components: [],
  });
 
  // 後續處理邏輯(下一節會擴充成兩段式確認流程)
}

選擇「後端」後,訊息更新且選單消失


動態選項:從外部資料建立

選項不一定要硬寫死。從 Sheets 或其他來源動態組選項:

async function buildCategoryMenu() {
  const rows = await getCategoryList(); // [["後端"], ["前端"], ["DevOps"]]
 
  const options = rows
    .map((row) => row[0]?.trim())
    .filter(Boolean)
    .map((name) =>
      new StringSelectMenuOptionBuilder().setLabel(name).setValue(name)
    );
 
  return new ActionRowBuilder().addComponents(
    new StringSelectMenuBuilder()
      .setCustomId("select_category")
      .setPlaceholder("選擇分類")
      .addOptions(options)
  );
}

使用時:

if (commandName === "search") {
  const row = await buildCategoryMenu();
  await interaction.reply({ content: "請選擇分類:", components: [row], flags: 64 });
}

注意:StringSelectMenu 的選項最多 25 個,上限和 Slash Command 的 choices 一樣。超過就需要改用 Autocomplete 或分頁的設計。


多選模式

預設是單選,可以用 setMinValues 和 setMaxValues 開啟多選:

new StringSelectMenuBuilder()
  .setCustomId("select_tags")
  .setPlaceholder("選擇標籤(最多 3 個)")
  .setMinValues(1)
  .setMaxValues(3)
  .addOptions(options)

interaction.values 此時會是選了多少就多長的陣列。


和 Button 搭配:兩段式互動

最常見的模式是:選單選擇 → 確認按鈕。ButtonBuilder 和 ButtonStyle 需要補進 import:

import {
  ActionRowBuilder,
  StringSelectMenuBuilder, StringSelectMenuOptionBuilder,
  ButtonBuilder, ButtonStyle,
} from "discord.js";

第一步:使用者從選單選完後,Bot 將訊息改為確認按鈕:

async function handleCategorySelect(interaction) {
  const [category] = interaction.values;
 
  const confirmRow = new ActionRowBuilder().addComponents(
    new ButtonBuilder()
      .setCustomId(`confirm_search_${category}`)
      .setLabel(`搜尋「${category}」`)
      .setStyle(ButtonStyle.Primary),
    new ButtonBuilder()
      .setCustomId("cancel_search")
      .setLabel("取消")
      .setStyle(ButtonStyle.Secondary)
  );
 
  await interaction.update({
    content: `確定要搜尋分類「${category}」嗎?`,
    components: [confirmRow],
  });
}

從選單選擇分類後,訊息替換為確認按鈕(藍色「搜尋」+ 灰色「取消」)

第二步:使用者按下確認,Bot 查詢並回覆結果:

async function handleConfirmSearch(interaction, category) {
  await interaction.deferUpdate(); // 先告訴 Discord「正在處理」,避免 3 秒逾時
 
  const results = await searchByCategory(category); // 換成你的資料查詢邏輯
 
  await interaction.editReply({
    content: results.length
      ? `找到 ${results.length} 筆結果:\n${results.join("\n")}`
      : "沒有找到相關結果。",
    components: [],
  });
}
 
async function handleCancelSearch(interaction) {
  await interaction.update({ content: "已取消。", components: [] });
}

interaction.deferUpdate() 的作用類似 deferReply,告訴 Discord「我正在處理,先維持現在的畫面」,避免 3 秒逾時。處理完後用 editReply 更新訊息。与 deferReply 的差異在於:deferUpdate 用在元件互動(Button / SelectMenu),不會另外回一則新訊息,而是就地更新原訊息。


完整流程總覽

client.once("ready")
  └── guild.commands.set(COMMANDS)   ← 把 /search 註冊到 Discord

使用者輸入 /search
  └── interactionCreate → isChatInputCommand() → commandName === "search"
        └── buildCategoryMenu() + interaction.reply({ components: [row] })
              └── 使用者看到下拉選單

使用者選擇分類
  └── interactionCreate → isStringSelectMenu() → customId === "select_category"
        └── handleCategorySelect(interaction)
              └── interaction.update({ components: [confirmRow] })
                    └── 使用者看到確認按鈕

使用者按下確認
  └── interactionCreate → isButton() → customId.startsWith("confirm_search_")
        └── handleConfirmSearch(interaction, category)
              └── interaction.deferUpdate()
              └── searchByCategory(category)
              └── interaction.editReply({ content: 結果, components: [] })

完整骨架總結

把這篇所有概念組合在一起,index.js 的下拉選單互動結構長這樣:

index.js(下拉選單完整骨架)
import {
  Client, GatewayIntentBits,
  ActionRowBuilder,
  StringSelectMenuBuilder, StringSelectMenuOptionBuilder,
  ButtonBuilder, ButtonStyle,
} from "discord.js";
import "dotenv/config";
 
const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});
 
// ── 1. 指令定義 ──────────────────────────────────────────────
const COMMANDS = [
  { name: "search", description: "依分類搜尋" },
];
 
// ── 2. ready:註冊指令 ────────────────────────────────────────
client.once("ready", async () => {
  const guild = await client.guilds.fetch(process.env.GUILD_ID);
  await guild.commands.set(COMMANDS);
  console.log("✅ Bot 上線,指令已更新");
});
 
// ── 3. 動態選單建立(從 Sheets 或其他資料來源讀取) ────────────
async function getCategoryList() {
  // mock:換成你的資料來源,例如從 Google Sheets 讀取
  return [["後端"], ["前端"], ["DevOps"]];
}
 
async function buildCategoryMenu() {
  const rows = await getCategoryList();
  const options = rows
    .map((r) => r[0]?.trim())
    .filter(Boolean)
    .map((name) => new StringSelectMenuOptionBuilder().setLabel(name).setValue(name));
 
  return new ActionRowBuilder().addComponents(
    new StringSelectMenuBuilder()
      .setCustomId("select_category")
      .setPlaceholder("選擇分類")
      .addOptions(options)
  );
}
 
// ── 4. interactionCreate:Select Menu + Button 分流 ──────────
client.on("interactionCreate", async (interaction) => {
 
  // 第一層:下拉選單
  if (interaction.isStringSelectMenu()) {
    if (interaction.customId === "select_category") return handleCategorySelect(interaction);
    return;
  }
 
  // 第二層:按鈕(select menu 選完後的確認 / 取消)
  if (interaction.isButton()) {
    if (interaction.customId.startsWith("confirm_search_")) {
      const category = interaction.customId.replace("confirm_search_", "");
      return handleConfirmSearch(interaction, category);
    }
    if (interaction.customId === "cancel_search") return handleCancelSearch(interaction);
    return;
  }
 
  // 第三層:Slash Command
  if (!interaction.isChatInputCommand()) return;
  if (interaction.commandName === "search") {
    const row = await buildCategoryMenu();
    await interaction.reply({ content: "請選擇分類:", components: [row], flags: 64 });
  }
});
 
// ── 5. handler:第一步,選完 → 顯示確認按鈕 ─────────────────
async function handleCategorySelect(interaction) {
  const [category] = interaction.values;
 
  const confirmRow = new ActionRowBuilder().addComponents(
    new ButtonBuilder()
      .setCustomId(`confirm_search_${category}`)
      .setLabel(`搜尋「${category}」`)
      .setStyle(ButtonStyle.Primary),
    new ButtonBuilder()
      .setCustomId("cancel_search")
      .setLabel("取消")
      .setStyle(ButtonStyle.Secondary)
  );
 
  await interaction.update({
    content: `確定要搜尋分類「${category}」嗎?`,
    components: [confirmRow],
  });
}
 
// ── 6. handler:第二步,確認 → 查詢並回覆結果 ───────────────
async function handleConfirmSearch(interaction, category) {
  await interaction.deferUpdate();
 
  const results = await searchByCategory(category); // 換成你的資料查詢邏輯
 
  await interaction.editReply({
    content: results.length
      ? `找到 ${results.length} 筆結果:\n${results.join("\n")}`
      : "沒有找到相關結果。",
    components: [],
  });
}
 
async function handleCancelSearch(interaction) {
  await interaction.update({ content: "已取消。", components: [] });
}
 
client.login(process.env.BOT_TOKEN);

核心模式是:指令觸發 → reply 附帶選單 → 使用者選擇 → isStringSelectMenu() 路由 → update() 換成確認按鈕 → isButton() 路由 → deferUpdate() + editReply() 回傳結果。


到這裡,discord.js 的三種主要互動——Slash Commands、Button、StringSelectMenu——都有了基礎設計。實際的 Bot 功能幾乎都是這三種組合出來的。

下一篇會介紹 Embed:用 EmbedBuilder 打造有顏色條、標題、欄位的結構化訊息,讓 Bot 回應從純文字升級為清晰易讀的卡片格式。

分享:XLinkedIn
← 上一篇Discord Bot 按鈕互動:ActionRow、ButtonBuilder 與事件處理
下一篇 →Discord Bot Embed:用 EmbedBuilder 打造結構化訊息