この記事は、会津大学学園祭実行委員会 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つの条件を満たす必要がある。
- 文字を必ず含むこと。
.addTextDisplayComponents(TextDisplayBuilder)を使用。
- 文字はmd形式で記述する。
- サムネイルまたはボタンを含むこと。(以下の関数を使用)
.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のように指定しておけば、初回だけファイル添付をするだけになるので楽。
使ってみた感想
調べても資料が少なかったので割と苦戦はしたものの、比較的自由に組めた。
慣れれば割と使いやすいので是非試してほしい。