discord.jsでEmbed表示とボタンを使いこなそう


概要

Discord Botでメッセージを送るとき、普通のテキストだけだと情報が伝わりにくいことがあります。discord.jsには Embed(色付きのカードUI)と Button(クリックできるボタン)という仕組みがあります。これらを使うと、Botの見た目も操作性も向上します。

この記事では、discord.jsのEmbed・Button・メッセージ編集を使って、以下のことができるようになります:

  • カード型のリッチなメッセージを投稿する
  • メッセージにクリック可能なボタンを付ける
  • ボタンが押されたら処理を実行する
  • 処理中はボタンを無効化して連打を防ぐ
  • 新しいメッセージを増やさず、既存メッセージを編集して更新する

完成イメージ:
Discordの通話を通して話した内容を議事録として残すようにするため、Embedを使用しました。

青い線があるエリアが カード で、下部の 🔄再生成 がボタンになります。
discord-embed-button

今回詳しく記述してないですが、議事録生成にはAWS BedrockのClaudeを呼び出す仕組みにしています。

前提条件

この記事では以下が済んでいる前提で進めます:

  • Node.js と discord.js(2026/4/3時点最新14.25.1) がインストール済み
  • Bot トークンを取得してログインできる状態
  • JavaScript / TypeScript の基本的な書き方(変数・関数・非同期処理)がわかる

discord.js のセットアップがまだの場合は、discord.js を先に参照してください。

手順

1. まずEmbedって何? — テキストメッセージとの違い

DiscordのEmbed(埋め込み)は、Botだけが使える特別なメッセージ形式です。普通のテキストメッセージと比べると以下のような違いがあります:

テキストメッセージEmbed
文字数上限2,000文字description: 4,096文字
見た目普通のチャット色付きカード型
見出し(#)対応対応
太字・リスト対応対応
タイトルなし専用のタイトル欄
タイムスタンプなし右下に自動表示
カラーバーなし左端に色付きライン

文字数が2倍以上使えて、見た目も整います。議事録のような長めのテキストや、まとまった情報を表示したいときに適しています。

2. EmbedBuilderでカード型メッセージを作ろう

discord.jsでは EmbedBuilder クラスでEmbedを組み立てます。メソッドチェーンで設定を積み重ねていくスタイルです:

import { EmbedBuilder } from "discord.js";

const embed = new EmbedBuilder()
  .setTitle("📝 議事録") // カード上部に大きく表示されるタイトル
  .setDescription("ここに本文") // カード本体のテキスト(Markdown対応)
  .setColor(0x5865f2) // 左端のカラーバー
  .setTimestamp(); // 右下に現在時刻を自動表示

メソッドチェーンについて: .setTitle().setDescription().setColor() のように、メソッド(関数)を . でつなげて呼び出す書き方です。各メソッドが自分自身(this)を返すため、連続して呼び出せる仕組みになっています。

0x5865f2 について: 0x は「これは16進数です」という意味です。5865F2 はDiscord公式の紫色(Discord Blurple)のカラーコードで、Webのカラーコード #5865F2 と同じものです。

4,096文字の上限に注意: Embedのdescriptionは4,096文字までしか入りません。超えた場合は切り詰める必要があります:

.setDescription(
  text.length > 4096 ? `${text.slice(0, 4093)}...` : text,
)

今回は slice(0, 4093) で先頭4,093文字を取り出して ... を付けています。

Embed内のMarkdownについて: description内では ##### の見出し、**太字**- リスト がそのまま使えます。テキストメッセージと同じMarkdownが使用できます。

3. Embedを送信しよう

組み立てたEmbedは、embeds プロパティに配列で渡して送信します:

await channel.send({
  embeds: [embed], // Embedの配列(最大10個まで)
});

1つのメッセージに複数のEmbedを付けられる設計になっているため、1つだけでも配列で渡す必要があります。

4. ButtonBuilderでボタンを作ろう

discord.jsのボタンは ButtonBuilder で作成します。ボタンは「コンポーネント」の一種で、ActionRowBuilder(1行分のコンポーネントの入れ物)に入れてからメッセージに添付する仕組みになっています:

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

const button = new ButtonBuilder()
  .setCustomId("my_button") // ボタンを識別するためのID
  .setLabel("再生成") // ボタンに表示するテキスト
  .setEmoji("🔄") // ボタンに表示する絵文字
  .setStyle(ButtonStyle.Secondary) // ボタンの色
  .setDisabled(false); // true にすると押せなくなる

// <ButtonBuilder> はTypeScriptの型指定。「このRowはボタン用です」という意味
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);

ボタンのスタイル(色)一覧:

スタイル用途の目安
Primaryメインの操作
Secondaryサブの操作
Success成功・確認系
Danger削除・危険な操作
Link灰(リンクアイコン付き)外部URLへのリンク

Important ボタンが押されたとき、Bot側では customId で「どのボタンが押されたか」を判定します。HTMLでいう id 属性のようなユニークな値で、この値が後述のイベントハンドラと紐づいています。

5. Embedとボタンを一緒に送信しよう

Embedとボタンは同じメッセージに同居できます。embedscomponents をそれぞれ指定するだけです:

await channel.send({
  embeds: [embed], // Embedの配列
  components: [row], // ボタン行の配列(最大5行まで)
});

これで、カード型のメッセージの下にクリックできるボタンが表示されます。

6. ボタンが押されたときの処理を書こう

インタラクションについて: ユーザーがボタンを押したりスラッシュコマンドを実行したりしたとき、discord.jsが受け取る「操作のイベント情報」のことです。誰が・どこで・何を操作したかという情報が詰まっています。

ボタンが押されると interactionCreate イベントが発火します。スラッシュコマンドと同じイベントなので、まず「これはボタンか?」を判定して分岐します:

client.on("interactionCreate", async (interaction) => {
  // ボタンの判定
  if (interaction.isButton() && interaction.customId === "my_button") {
    // ボタンが押されたときの処理
    await interaction.reply("ボタンが押されました!");
    return;
  }
});

interaction.isButton() について: このインタラクションがボタン押下かどうかを判定するメソッドです。discord.jsにはスラッシュコマンド、ボタン、セレクトメニューなど様々なインタラクション種別があるため、最初にこれで種別を判定します。

customId との照合: interaction.customId === "my_button" で、ステップ4で設定した setCustomId("my_button") と一致するかチェックします。ボタンが複数ある場合は、ここでどのボタンが押されたかを判別できます。

7. deferUpdate() — ボタン押下時のレスポンスパターン

Discordのインタラクションには「3秒以内にレスポンスを返す」というルールがあります。処理に時間がかかる場合は、先に「処理待ち」状態を通知する必要があります。

ボタンの場合、2つのパターンがあります:

メソッド何が起きるかいつ使う
deferReply()「Botが考え中…」と新しいメッセージが表示されるボタンを押した結果、新しいメッセージを返したいとき
deferUpdate()何も表示されない(既存メッセージをそのまま維持)ボタンを押した結果、既存メッセージを編集するだけのとき

「再生成ボタンを押したら、同じメッセージの中身を書き換えたい」という場合は deferUpdate() を使用します:

await interaction.deferUpdate();

// 時間のかかる処理...(ex.AIの回答生成待ちなど)
const newEmbed = new EmbedBuilder()...
const newRow = new ActionRowBuilder<ButtonBuilder>()...

// 既存メッセージを編集
await interaction.message.edit({
  embeds: [newEmbed],
  components: [newRow],
});

8. ボタンの無効化と復帰 — 連打を防ぐパターン

ボタンを押してから処理が完了するまでの間、同じボタンを連打されると問題が起きることがあります。そこで 処理中はボタンを無効化して、完了したら元に戻す というパターンを使います:

async function handleButton(interaction: ButtonInteraction) {
  await interaction.deferUpdate();

  // ① ボタンを即座に無効化(見た目も変える)
  await interaction.message.edit({
    components: [buildRow(true)], // disabled = true
  });

  try {
    // ② 時間のかかる処理
    const result = await heavyProcess();

    // ③ 完了: 内容を更新してボタンを有効化に戻す
    await interaction.message.edit({
      embeds: [buildEmbed(result)],
      components: [buildRow(false)], // disabled = false
    });
  } catch (err) {
    // ④ エラー時もボタンを有効化に戻す(リトライできるように)
    await interaction.message
      .edit({ components: [buildRow(false)] })
      .catch(() => {});
    await interaction.followUp({
      content: "処理に失敗しました。",
      ephemeral: true, // 押した本人だけに見えるメッセージ(他のユーザーには非表示)
    });
  }
}

状態を切り替えるビルダー関数は以下のようになります:

function buildEmbed(content: string): EmbedBuilder {
  const formatted = formatForEmbed(content);
  const truncated = formatted.length > 4096;
  const embed = new EmbedBuilder()
    .setTitle("📝 議事録")
    .setDescription(truncated ? `${formatted.slice(0, 4093)}...` : formatted)
    .setColor(EMBED_COLOR)
    .setTimestamp();
  if (truncated) {
    embed.setFooter({ text: "※ 文字数制限により一部省略されています" });
  }
  return embed;
}

function buildRow(disabled = false): ActionRowBuilder<ButtonBuilder> {
  const button = new ButtonBuilder()
    .setCustomId("my_button")
    .setLabel(disabled ? "処理中…" : "実行")
    .setEmoji(disabled ? "⏳" : "🔄")
    .setStyle(ButtonStyle.Secondary)
    .setDisabled(disabled);
  return new ActionRowBuilder<ButtonBuilder>().addComponents(button);
}

Important エラー時にボタンを元に戻さないと、ボタンが永久に「⏳ 処理中…」のまま押せなくなってしまいます。.catch(() => {}) は「この復帰処理自体が失敗しても無視する」という安全策です。

9. メッセージ編集 — 新しく投稿せずに内容を更新しよう

同じ操作を何度も実行するたびにメッセージが増えていくと、チャットが見づらくなります。そこで、前回のメッセージのIDを覚えておいて、次回はそのメッセージを編集する というテクニックが使えます:

// メッセージIDを保存しておく変数(セッション情報の一部として持つ)
let lastMessageId: string | undefined;

async function sendOrUpdate(channel, embed, row) {
  // 既存メッセージがあれば編集を試みる
  if (lastMessageId) {
    try {
      const existing = await channel.messages.fetch(lastMessageId);
      await existing.edit({ embeds: [embed], components: [row] });
      return; // 編集成功
    } catch {
      // メッセージが削除されていた等 → 新規投稿にフォールバック(後述)
    }
  }

  // 新規投稿してIDを保存
  const sent = await channel.send({ embeds: [embed], components: [row] });
  lastMessageId = sent.id;
}

なぜメッセージIDだけ保存するのか? discord.jsのMessageオブジェクトをそのまま保存してもよいですが、Messageオブジェクトはキャッシュの状態によって古い情報を持っている場合があるため、IDだけ保存して channel.messages.fetch(id) で都度取得する方が確実です。

Important メッセージがユーザーによって手動削除されている場合、fetch が失敗します。catch で捕まえてフォールバック(うまくいかなかった場合の代替手段)として新規投稿することで、どんな状況でも必ず結果が表示されるようになります。

参考