2005 文字
10 分
discord.jsでitoのbotを作ってみる 【Components v2 / New Modal】

この記事は、会津大学学園祭実行委員会 Advent Calendar 2025の記事です。

こんな人におすすめ#

  • discord.jsを触り慣れてる人
  • Components v2ってなんだよ!って人
  • Components v2の仕様・Modalの新仕様がわからん!って人

discord.jsとは#

一応discord.jsについて説明。

discord.jsは、Node.js上でDiscordのbotを動かす上でやりやすくしてくれてるやつ。

discord.jsの細かいものはここから。

Components v2について#

Components v2は、2025年3月頃からDiscordに導入されてる新しいComponentの仕様。

簡単に言うと、埋め込みの中にMessage Component(Button・SelectMenu)を混ぜれるようになった。

従来のMessage Componentはメッセージの下しか配置されない仕様だったが、Components v2はメッセージ全体を1つのComponentとして構築する新しい方法であるため、レイアウトの自由度が大幅に向上した。

discord.jsでは、v14.19.0からComponents v2をサポートするようになった。

Modalの新仕様について#

Modal自体はかなり前から実装されているが、Components v2の仕様に合わせてdiscord.js v14.24.0以降から新しいModalの仕様がサポートされるようになった。

従来のModalは単一行または複数行の文字入力のみだったが、新仕様ではSelectMenuの埋め込みや、ファイルの受付などが可能になった。

細かい仕様たち#

調べようとしても記事がそんなになかったので苦戦したが、まずComponents v2の仕様はこんな感じ。

  • 送信する際、flags: MessageFlags.IsComponentsV2を指定する必要がある。
  • Components v2は、Containerという単位で構成される。
    • ContainerBuilderを使用すると楽に作成可能
    • ContainerBulderには以下の関数がある。
      • .addActionRowComponents: ActionRow(Button / SelectMenu)を追加する。
        • 引数としてActionRowBuilderを渡す。
        • このbotでも使用している。
      • .addFileComponents: ファイルを添付できる。
        • 引数としてFileBuilderを渡す。
      • .addMediaGalleryComponents: 画像を添付できる。
        • 引数としてMediaGalleryBuilderを渡す。
      • .addSeperatorComponents: セパレーターを追加する。
        • 引数としてSeperatorBuilderを渡す。
        • このbotでも使用している。
      • .addTextDisplayComponents: Markdown形式の文字を表示する。
        • 引数としてTextDisplayBuilderを渡す。
        • このbotでも使用している。
      • .addSectionComponents: Sectionを追加する。
        • Markdown形式の文字に加え、その右にボタンまたは画像を表示することができる。
        • 引数としてSectionBuilderを渡す。
        • Sectionを使用する際は3つの条件を満たす必要がある。
          1. 文字を必ず含むこと。
            • .addTextDisplayComponents(TextDisplayBuilder)を使用。
          2. 文字はmd形式で記述する。
          3. サムネイルまたはボタンを含むこと。(以下の関数を使用)
            • .setThumbnailAccessory(ThumbnailBuilder)
            • .setButtonAccessory(ButtonBuilder)
        • このbotでも使用している。

うーん…長い!

ただ、それだけ自由度が上がってるということでもある。

続いて、Modalの新仕様について。

  • 基本的にはModalの従来仕様と同じ。
  • .setDescriptionではなく、.addTextDisplayComponentsを使用する。
    • これにより、項目と項目の間にMarkdown形式の文字を表示できる。
  • .addComponents/.setComponentsではなく、.addLabelComponentsを使用する。
    • 引数としてLabelBuilderを渡す。
    • LabelBuilderには、以下の関数がある。ラベルと、中身となる項目(どれか1つ)が必須。
      • .setLabel: ラベルを設定する。必須。
      • .setDescription: 説明文を設定する。
      • .setTextInputComponent: 文字入力欄を設定する。
        • 引数としてTextInputBuilderを渡す。
      • .setStringSelectMenuComponent: 文字列のSelectMenuを設定する。
        • 引数としてStringSelectMenuBuilderを渡す。
      • .setMentionableSelectMenuComponent: メンション可能な文字列のSelectMenuを設定する。
        • 引数としてStringSelectMenuBuilderを渡す。
      • .setUserSelectMenuComponent: ユーザーのSelectMenuを設定する。
        • 引数としてUserSelectMenuBuilderを渡す。
      • .setRoleSelectMenuComponent: ロールのSelectMenuを設定する。
        • 引数としてRoleSelectMenuBuilderを渡す。
      • .setChannelSelectMenuComponent: チャンネルのSelectMenuを設定する。
        • 引数としてChannelSelectMenuBuilderを渡す。
      • .setFileUploadComponent: ファイルを受け取る欄を設定する。
        • 引数としてFileUploadBuilderを渡す。
  • こちらはフラグを設定する必要はない。

実際にやってみた#

discord.jsをTypeScriptで書こうとすると永遠に型定義が読み込まれなかったので断念。

スラッシュコマンドで開始処理をしているが、スラッシュコマンドの実装などは割愛する。

実装部分#

src/functions/startGame.js(表示)

import { AttachmentBuilder, MessageFlags } from "discord.js";
import createJoinContainer from "./createJoinContainer.js";
export default async function startGame(interaction) {
// itoのゲームデータは、index.jsでclient.gameData (Collection型)に保存されている。
const { client: { gameData }, id, channel, user } = interaction;
const reply = await interaction.reply({
components: [
// ここのcreateJoinContainerは後述するもの。
createJoinContainer({ id, users: [{ id: user.id }] })
],
files: [
new AttachmentBuilder()
.setFile('./files/ito.png')
.setName('ito.png')
],
// fetchReplyが非推奨になったため、withResponseを使用する。
// なお、fetchReplyにしたときの返り値とは異なるので注意。
withResponse: true,
// IsComponentsV2は必須。
// SuppressNotificationは、ユーザーが@silentをつけて送信したのと同様の動きをする。
flags: [
MessageFlags.IsComponentsV2,
MessageFlags.SuppressNotifications
]
});
return gameData.set(channel.id, {
users: [{ id: user.id }],
channelId: channel.id,
messageId: reply.interaction?.responseMessageId || reply.resource?.message?.id,
life: 3,
turn: 1
});
}

src/functions/createJoinContainer.js(中身)

import {
ContainerBuilder,
SectionBuilder,
TextDisplayBuilder,
ThumbnailBuilder,
ActionRowBuilder,
ButtonBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
ButtonStyle
} from 'discord.js'
export default function createJoinContainer({ id, users = [] }) {
if (users.length == 0) {
return new ContainerBuilder()
.addSectionComponents(
new SectionBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder()
.setContent('## itoの募集は終了しました。')
)
.setThumbnailAccessory(
new ThumbnailBuilder()
.setURL('attachment://ito.png')
)
)
.setAccentColor(0xffaaaa)
.setSpoiler(false)
}
return new ContainerBuilder()
.addSectionComponents(
new SectionBuilder()
.addTextDisplayComponents(
new TextDisplayBuilder()
.setContent('## itoの募集を開始しました!\n参加するには下のボタンを押してください。\n最低4人でゲームを開始できます。')
)
.setThumbnailAccessory(
new ThumbnailBuilder()
.setURL('attachment://ito.png')
)
)
.addActionRowComponents(
new ActionRowBuilder()
.setComponents(
new ButtonBuilder()
.setCustomId(`ito_join_${id}`)
.setLabel('参加する')
.setStyle(ButtonStyle.Primary),
// 各ユーザーごとに表示を切り替えることができないので、参加キャンセルを押したあとに判定をしている。
new ButtonBuilder()
.setLabel('参加キャンセル')
.setStyle(ButtonStyle.Danger)
.setCustomId(`ito_cancel_${id}`)
new ButtonBuilder()
.setCustomId(`ito_begin_${id}`)
.setLabel('ゲーム開始')
.setStyle(ButtonStyle.Success)
.setDisabled(users.length < 4)
)
)
.addSeparatorComponents(
new SeparatorBuilder()
// setDividerをtrueにしないと、セパレータが表示されない。
.setDivider(true)
// setSpacingを設定することで、セパレータの間隔を調整できる。
.setSpacing(SeparatorSpacingSize.Large)
).addTextDisplayComponents(
new TextDisplayBuilder()
.setContent(`## 現在の参加者: ${users.length}人${users.length ? "\n" : ""}${users.map((u, i) => `- <@${u.id}>${!i ? " (オーナー)" : ""}`).join('\n')}`)
)
.setAccentColor(0xaaffaa)
.setSpoiler(false)
}

実行してみるとこんな感じ。 実行結果

次にModalの実装をしてみる。

このbotでは、お題を入力したり回答を入力したりする際にModalを使用している。

このModalではファイルのアップロード機能などはつけていないので、従来仕様からmd形式のテキストを追加しただけである。

実装部分#

src/function/submitTheme.js

import { LabelBuilder, MessageFlags, ModalBuilder, TextDisplayBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
export default async function submitTheme(interaction) {
const { client: { gameData }, channel, user, message } = interaction;
const game = gameData.get(channel.id);
if (!game) {
await interaction.reply({
content: 'このチャンネルでは現在itoは進行していません。',
flags: [MessageFlags.Ephemeral],
});
setTimeout(() => {
return interaction.deleteReply();
}, 3000);
return;
}
if (game.messageId !== message.id) {
await interaction.reply({
content: 'このメッセージは現在のitoのものではありません。',
flags: [MessageFlags.Ephemeral],
});
setTimeout(() => {
return interaction.deleteReply();
}, 3000);
return;
}
if (user.id != game.users[0].id) {
await interaction.reply({
content: 'オーナーのみお題を選択可能です。',
flags: [MessageFlags.Ephemeral],
});
setTimeout(() => {
return interaction.deleteReply();
}, 3000);
return;
}
return await interaction.showModal(
new ModalBuilder()
.setCustomId(`ito_theme_modal_${interaction.id}`)
.setTitle('ito-お題入力')
.addTextDisplayComponents(
// Markdown形式のテキストを使用可能
new TextDisplayBuilder()
.setContent("お題+(1:〇〇〇~100:〇〇〇)の形式で入力してください。\n例: 大きい動物(1:アリ~100:ゾウ)")
)
.addLabelComponents(
new LabelBuilder()
.setLabel('お題')
.setTextInputComponent(
// 従来仕様をそのまま持ってくる感じ。
new TextInputBuilder()
.setCustomId(`ito_theme_submit`)
.setPlaceholder('例: 大きい動物 (1:小さい-100:大きい)')
.setStyle(TextInputStyle.Short)
)
)
)
}

こっちも実行してみるとこんな感じ。 実行結果

画像とかのファイルは、attachment://***.pngのように指定しておけば、初回だけファイル添付をするだけになるので楽。

使ってみた感想#

調べても資料が少なかったので割と苦戦はしたものの、比較的自由に組めた。

慣れれば割と使いやすいので是非試してほしい。