インタラクション(Button / Select / Modal)

メッセージにボタン・セレクトメニューを貼って、Modal(ダイアログ)で入力させる。 ユーザー操作のたびに InteractionCreate が飛んでくる。

ActionRow に並べる

ボタンや SelectMenu は ActionRow(最大 5 つ)に入れて送る。1 メッセージで最大 5 行。

Button

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

const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
  new ButtonBuilder()
    .setCustomId("confirm")
    .setLabel("OK")
    .setStyle(ButtonStyle.Primary),
  new ButtonBuilder()
    .setCustomId("cancel")
    .setLabel("キャンセル")
    .setStyle(ButtonStyle.Secondary),
  new ButtonBuilder()
    .setLabel("詳細")
    .setURL("https://example.com")
    .setStyle(ButtonStyle.Link),       // URL のみ
  new ButtonBuilder()
    .setCustomId("delete")
    .setLabel("削除")
    .setEmoji("🗑️")
    .setStyle(ButtonStyle.Danger),
)

await interaction.reply({
  content: "実行しますか?",
  components: [row],
})

ボタンスタイル

ボタンの押下を受ける

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isButton()) return

  if (interaction.customId === "confirm") {
    await interaction.update({ content: "OKを受け付けました", components: [] })
  }
  if (interaction.customId === "cancel") {
    await interaction.update({ content: "キャンセルしました", components: [] })
  }
})

String Select Menu

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

const select = new StringSelectMenuBuilder()
  .setCustomId("color")
  .setPlaceholder("色を選んで")
  .setMinValues(1)
  .setMaxValues(2)        // 複数選択可能
  .addOptions(
    new StringSelectMenuOptionBuilder()
      .setLabel("赤").setValue("red").setEmoji("🟥"),
    new StringSelectMenuOptionBuilder()
      .setLabel("青").setValue("blue").setEmoji("🟦"),
  )

const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)
await interaction.reply({ content: "選択", components: [row] })

// ハンドラ
if (interaction.isStringSelectMenu() && interaction.customId === "color") {
  const values = interaction.values     // string[]
  await interaction.update({ content: `選択: ${values.join(", ")}` })
}

その他の SelectMenu 種別

Modal(ダイアログで入力)

import {
  ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder,
} from "discord.js"

// ボタン押下時に Modal を出す
if (interaction.isButton() && interaction.customId === "open_form") {
  const modal = new ModalBuilder()
    .setCustomId("feedback")
    .setTitle("フィードバック")

  const titleInput = new TextInputBuilder()
    .setCustomId("title")
    .setLabel("タイトル")
    .setStyle(TextInputStyle.Short)
    .setRequired(true)
    .setMaxLength(100)

  const bodyInput = new TextInputBuilder()
    .setCustomId("body")
    .setLabel("内容")
    .setStyle(TextInputStyle.Paragraph)
    .setMinLength(10)
    .setMaxLength(2000)

  modal.addComponents(
    new ActionRowBuilder<TextInputBuilder>().addComponents(titleInput),
    new ActionRowBuilder<TextInputBuilder>().addComponents(bodyInput),
  )

  await interaction.showModal(modal)
}

// Modal 送信を受ける
if (interaction.isModalSubmit() && interaction.customId === "feedback") {
  const title = interaction.fields.getTextInputValue("title")
  const body  = interaction.fields.getTextInputValue("body")
  await interaction.reply({ content: `受付: ${title}`, ephemeral: true })
}

TextInputStyle

customId に状態を埋め込む

ボタンの customIdJSON や ID を埋め込んで後でパースする定石。

// 送信時
new ButtonBuilder().setCustomId(`vote:${pollId}:yes`)

// 受信時
const [kind, pollId, value] = interaction.customId.split(":")
if (kind === "vote") {
  await recordVote(pollId, value, interaction.user.id)
}

collector パターン(特定メッセージの応答だけ拾う)

const message = await interaction.reply({
  content: "実行しますか?",
  components: [row],
  fetchReply: true,
})

const collector = message.createMessageComponentCollector({
  filter: (i) => i.user.id === interaction.user.id,   // 本人だけ
  time: 60_000,                                        // 60秒
  max: 1,
})

collector.on("collect", async (i) => {
  await i.update({ content: `押した: ${i.customId}`, components: [] })
})

collector.on("end", async (collected, reason) => {
  if (collected.size === 0) {
    await interaction.editReply({ content: "タイムアウト", components: [] })
  }
})

カスタム入力の検証

Modal や Select の値は必ずサーバー側で検証する(Discord 経由でも改ざんされうるという前提で)。

Components V2(新しい構文)

Discord はComponents V2(より柔軟なレイアウト)を導入中。 通常の ActionRow ベースで十分だが、複雑な UI には V2 を検討する余地がある。 詳しくは 公式 docs

注意点