Use Cases (更新: 2026/6/3)

Claude CodeでDiscord Botを作る実践ガイド:discord.jsとSlash Commandの本番手順

Claude Codeとdiscord.jsで、サポート受付用Discord Botを安全に作る実装手順。

Claude CodeでDiscord Botを作る実践ガイド:discord.jsとSlash Commandの本番手順

Discord Botは「雑談の自動化」ではなく受付窓口である

Discord Botは、Discordサーバー内でユーザー操作を受け取り、決められた処理を返す小さなアプリです。初心者向けに言い換えると、コミュニティの中に置く「自動受付担当」です。質問を受け付ける、FAQを返す、モデレーターに引き継ぐ、障害連絡を専用チャンネルへ整形して送る、といった反復作業を任せられます。

ただし、Claude Codeに「Discord Botを作って」とだけ頼むと、動くデモは出ても本番では弱いことがあります。Bot tokenの扱い、slash commandの登録先、interactionへの応答期限、権限、ログ、失敗時の返信まで指定しないと、サーバーに入れた直後に止まるBotになりがちです。

この記事では、Claude Codeを使いながら、discord.jsでサポート受付Botを作る手順に絞ります。実装するのは /support/faq/handoff の3つです。Node.jsはdiscord.js公式ドキュメントに合わせて22.12.0以上を前提にします。コードはJavaScriptのES modulesで、TypeScript設定を作らなくてもコピペで試せる形にします。

flowchart LR
  A["User runs /support"] --> B["Discord interaction"]
  B --> C["discord.js bot"]
  C --> D["Ephemeral user reply"]
  C --> E["Support channel message"]
  E --> F["Moderator handoff"]

この構成の価値は、AIらしい長文回答ではなく、業務の通り道を固定できることです。質問が一般チャンネルに流れて消える状態から、受付番号のように扱えるメモへ変わります。Claude Codeは、Bot本体だけでなく、.env、README、権限チェック、デプロイ前チェックリストまで一緒に作らせると効果が出ます。

Application commandsとinteractionsを平易に整理する

Discordのapplication commandsは、Discordクライアントの入力欄に表示される公式のコマンド機能です。代表例が /support のようなslash commandです。昔の !help のようなメッセージ監視型コマンドより、名前、説明、入力欄、選択肢、権限をDiscord側のUIで示せるため、サポート受付にはこちらが向いています。

Interactionsは、ユーザーがslash command、ボタン、セレクトメニュー、モーダルを使ったときにBotへ届くイベントです。discord.jsのGateway接続では Events.InteractionCreate で受け取ります。DiscordはHTTP Webhookで受ける方法も用意していますが、最初のBotではGatewayのほうがローカル実行とログ確認が簡単です。

仕様は必ず公式を見てください。コマンド名、オプション、guild commandとglobal commandの違いは Discord Application Commands にまとまっています。interactionは最初の応答に期限があり、遅い処理ではdeferが必要になります。詳しくは Receiving and Responding to Interactions が基準です。discord.jsの現在の要件やAPIは discord.js documentationdiscord.js guide を確認します。

先に決める権限と環境変数

Developer Portalで作るDiscord applicationには、少なくともBotユーザーと applications.commands scopeが必要です。招待URLを作るときは、最初から管理者権限を与えないでください。今回のBotが必要とするのは、サポート投稿先チャンネルを閲覧して送信する権限です。/handoff を実行する人にはDiscordサーバー側で Manage Messages 相当の運用権限を持たせます。

項目注意点
Node.js22.12.0以上discord.js公式要件に合わせる
scopebot, applications.commandsslash command登録に必要
Bot permissionsView Channels, Send Messages最小権限から始める
DISCORD_TOKENBot tokenGit、記事、ログに出さない
DISCORD_CLIENT_IDApplication IDDeveloper Portalからコピー
DISCORD_GUILD_IDテスト用サーバーID開発中はguild command登録に使う
SUPPORT_CHANNEL_ID受付先チャンネルIDBotが送信できることを確認

Claude Codeへの依頼は「この表を満たすBotを作って。権限は最小、秘密情報は .env、コマンドはguild登録から始める」と書くと安定します。詳しい環境変数の分け方は Claude Codeで環境変数を安全に管理する、失敗時の返し方は Claude Codeでエラー処理パターンを整える も参考になります。

コピペで動くdiscord.jsスターター

以下は最小構成ですが、サポート受付、FAQ、モデレーター引き継ぎ、エラー時のephemeral reply、危険なmentionの無効化を入れています。DISCORD_GUILD_ID がある間はテストサーバーだけにコマンドを登録し、安定してからglobal commandへ進めます。

mkdir discord-support-bot
cd discord-support-bot
npm init -y
npm install discord.js dotenv
mkdir src

package.jsontypestart を追加します。

{
  "type": "module",
  "scripts": {
    "start": "node src/bot.js"
  },
  "dependencies": {
    "discord.js": "latest",
    "dotenv": "latest"
  }
}

.env を作ります。実際の値はDeveloper PortalとDiscordクライアントの開発者モードから取得してください。

DISCORD_TOKEN=replace_with_bot_token
DISCORD_CLIENT_ID=replace_with_application_id
DISCORD_GUILD_ID=replace_with_test_guild_id
SUPPORT_CHANNEL_ID=replace_with_support_channel_id
DEPLOY_COMMANDS=true

src/bot.js は以下です。

import "dotenv/config";
import {
  Client,
  Events,
  GatewayIntentBits,
  MessageFlags,
  PermissionFlagsBits,
  REST,
  Routes,
  SlashCommandBuilder,
} from "discord.js";

const token = process.env.DISCORD_TOKEN;
const clientId = process.env.DISCORD_CLIENT_ID;
const guildId = process.env.DISCORD_GUILD_ID;
const supportChannelId = process.env.SUPPORT_CHANNEL_ID;

for (const [name, value] of Object.entries({ token, clientId, supportChannelId })) {
  if (!value) throw new Error(`${name} is required.`);
}

const commands = [
  new SlashCommandBuilder()
    .setName("support")
    .setDescription("Send a support request to the team")
    .addStringOption((option) =>
      option
        .setName("summary")
        .setDescription("What happened?")
        .setMaxLength(900)
        .setRequired(true),
    )
    .addStringOption((option) =>
      option
        .setName("severity")
        .setDescription("How urgent is it?")
        .setRequired(true)
        .addChoices(
          { name: "low", value: "low" },
          { name: "normal", value: "normal" },
          { name: "high", value: "high" },
        ),
    )
    .addStringOption((option) =>
      option
        .setName("context")
        .setDescription("Steps, links, or error messages")
        .setMaxLength(1500),
    ),
  new SlashCommandBuilder()
    .setName("faq")
    .setDescription("Show a short answer for a common topic")
    .addStringOption((option) =>
      option
        .setName("topic")
        .setDescription("FAQ topic")
        .setRequired(true)
        .addChoices(
          { name: "setup", value: "setup" },
          { name: "permissions", value: "permissions" },
          { name: "rollout", value: "rollout" },
        ),
    ),
  new SlashCommandBuilder()
    .setName("handoff")
    .setDescription("Create a moderator handoff note")
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
    .addUserOption((option) =>
      option.setName("target").setDescription("User to hand off").setRequired(true),
    )
    .addStringOption((option) =>
      option
        .setName("note")
        .setDescription("What should the next moderator know?")
        .setMaxLength(1500)
        .setRequired(true),
    ),
].map((command) => command.toJSON());

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.once(Events.ClientReady, (readyClient) => {
  console.log(`Logged in as ${readyClient.user.tag}`);
});

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

  try {
    if (!interaction.inGuild()) {
      await interaction.reply({
        content: "Please use this command inside the server.",
        flags: MessageFlags.Ephemeral,
      });
      return;
    }

    if (interaction.commandName === "support") await handleSupport(interaction);
    else if (interaction.commandName === "faq") await handleFaq(interaction);
    else if (interaction.commandName === "handoff") await handleHandoff(interaction);
    else await safeReply(interaction, "Unknown command.");
  } catch (error) {
    console.error("Interaction failed:", error);
    await safeReply(interaction, "Something went wrong. Please contact a moderator.");
  }
});

async function handleSupport(interaction) {
  const summary = interaction.options.getString("summary", true);
  const severity = interaction.options.getString("severity", true);
  const context = interaction.options.getString("context") ?? "No extra context.";
  const channel = await fetchSupportChannel();

  await channel.send({
    content: [
      "**New support request**",
      `Reporter: ${interaction.user.tag} (${interaction.user.id})`,
      `Severity: ${severity}`,
      `Channel: <#${interaction.channelId}>`,
      `Summary: ${neutralizeMentions(summary)}`,
      `Context: ${neutralizeMentions(context)}`,
    ].join("\n"),
    allowedMentions: { parse: [] },
  });

  await interaction.reply({
    content: "Thanks. Your request was sent to the support team.",
    flags: MessageFlags.Ephemeral,
  });
}

async function handleFaq(interaction) {
  const topic = interaction.options.getString("topic", true);
  const answers = {
    setup: "Install Node.js 22.12+, invite the bot with bot and applications.commands scopes, then run npm start.",
    permissions: "Start with View Channels and Send Messages. Reserve Manage Messages for moderator-only commands.",
    rollout: "Use guild commands for testing. Promote to global commands only after rollback and logging are checked.",
  };

  await interaction.reply({
    content: answers[topic],
    flags: MessageFlags.Ephemeral,
  });
}

async function handleHandoff(interaction) {
  if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
    await interaction.reply({
      content: "You need Manage Messages permission to use this command.",
      flags: MessageFlags.Ephemeral,
    });
    return;
  }

  const target = interaction.options.getUser("target", true);
  const note = interaction.options.getString("note", true);
  const channel = await fetchSupportChannel();

  await channel.send({
    content: [
      "**Moderator handoff**",
      `Target: ${target.tag} (${target.id})`,
      `From: ${interaction.user.tag} (${interaction.user.id})`,
      `Note: ${neutralizeMentions(note)}`,
    ].join("\n"),
    allowedMentions: { parse: [] },
  });

  await interaction.reply({
    content: "Handoff note created.",
    flags: MessageFlags.Ephemeral,
  });
}

async function fetchSupportChannel() {
  const channel = await client.channels.fetch(supportChannelId);
  if (!channel || !channel.isTextBased() || typeof channel.send !== "function") {
    throw new Error("SUPPORT_CHANNEL_ID must be a text channel the bot can send to.");
  }
  return channel;
}

function neutralizeMentions(value) {
  return value
    .replaceAll("@everyone", "@ everyone")
    .replaceAll("@here", "@ here")
    .replace(/<@!?(\d+)>/g, "user:$1")
    .replace(/<@&(\d+)>/g, "role:$1");
}

async function safeReply(interaction, content) {
  const payload = { content, flags: MessageFlags.Ephemeral };
  if (interaction.replied || interaction.deferred) await interaction.followUp(payload);
  else await interaction.reply(payload);
}

async function deployCommands() {
  const rest = new REST({ version: "10" }).setToken(token);
  const route = guildId
    ? Routes.applicationGuildCommands(clientId, guildId)
    : Routes.applicationCommands(clientId);

  await rest.put(route, { body: commands });
  console.log(guildId ? "Guild commands deployed." : "Global commands deployed.");
}

if (process.env.DEPLOY_COMMANDS === "true") {
  await deployCommands();
}

await client.login(token);

ローカルでは node --version で22.12.0以上を確認し、npm start を実行します。最初はテストサーバーで /support を叩き、Botが受付チャンネルへ送信できるか、ユーザー本人にはephemeral replyだけが見えるかを確認してください。次に /faq、最後に権限のあるユーザーだけで /handoff を試します。

ユースケース(実務で効く使いどころ)

1つ目のユースケースは、サポート受付です。質問者に長いフォームを踏ませる前に、summaryseveritycontext だけを取れば、最低限の triage ができます。triageは「緊急度や担当先を仕分ける作業」です。Masaが小さな検証サーバーで試したときも、自由投稿より /support のほうが状況、再現手順、優先度が揃いやすく、後から読む人の負担が減りました。

2つ目は、FAQの短縮導線です。Botに長いドキュメントを丸ごと喋らせるより、短い答えと正しいリンクを返すほうが実務では強いです。たとえばセットアップは1段落で返し、詳細は Claude Codeのはじめ方CLIツール開発ガイド に送ります。記事とBotの回答を同じナレッジベースで更新すると、古い回答が残りにくくなります。

3つ目は、モデレーターの引き継ぎです。夜間対応、課金関連、荒らし対応、障害報告では「誰が、誰について、何を次に見るか」が抜けると混乱します。/handoff はManage Messages権限を持つ人だけに制限し、専用チャンネルへ整形済みメモを送る設計にします。個人情報を本文に含めすぎないことも重要です。

4つ目を足すなら、研修コミュニティの受講者サポートです。Claude Codeの研修では、受講者が同じエラーを何度も投稿します。Botに「Nodeバージョン」「実行したコマンド」「最後のエラー10行」を聞かせるだけで、講師は原因に近い情報から対応できます。チーム導入の運用設計は 研修・相談 のCTAと相性が良いです。

本番前に潰す落とし穴

最初の落とし穴は、Bot tokenを記事、Git、スクリーンショット、ログへ出すことです。漏れたらDeveloper Portalで即時rotateし、古いtokenを無効化します。Claude Codeにコードを書かせるときも「token値を出力しない」「.env.example だけ作る」と指示してください。

次の落とし穴は、global commandを開発中に何度も登録することです。guild commandはテストサーバーだけに早く反映されます。global commandは本番公開用と考え、コマンド名、説明、権限、ロールバック手順が固まってから使います。登録失敗時に DEPLOY_COMMANDS=true のまま本番再起動を繰り返すのも避けます。

3つ目は、interactionへ返信しない経路を残すことです。成功時だけ reply して、例外時や未知コマンド時に何も返さないと、ユーザーには失敗理由が見えません。重い処理を入れる場合は先にdeferし、あとでfollow upします。今回のコードは短い処理なので即時replyで十分ですが、外部APIやLLMを呼ぶなら構成を変えてください。

4つ目は、mention爆撃です。ユーザー入力をそのまま受付チャンネルへ送ると、@everyone やロールmentionで通知事故が起きます。コードでは allowedMentions: { parse: [] } と文字列の無害化を両方入れています。これは地味ですが、公開サーバーでは必須です。

5つ目は、Botに管理者権限を渡すことです。管理者権限なら動作確認は楽ですが、侵害時の被害が広がります。最初はView ChannelsとSend Messagesに絞り、必要になった権限だけレビューして追加します。権限設計の考え方は コードレビュー チェックリスト にも近いです。

セキュリティとデプロイ前チェックリスト

  • Developer PortalでBot tokenを再発行できる担当者を決めた
  • .env はGit管理外、.env.example にはダミー値だけを書いた
  • invite URLは botapplications.commands scopeだけから始めた
  • Bot permissionsはView ChannelsとSend Messagesを基本にした
  • /handoff はManage Messages権限のある人だけが使える
  • DISCORD_GUILD_ID でテストしてからglobal commandへ進める
  • すべてのinteraction経路でreplyまたはfollow upがある
  • ユーザー入力のmentionを無効化している
  • ログにはtoken、メール、決済情報、個人情報を出さない
  • デプロイ先でNode.js 22.12.0以上を使っている
  • 障害時にBotを止める手順と、前のバージョンへ戻す手順がある

デプロイ先は小規模ならRailway、Render、Fly.io、VPS、社内サーバーなどで十分です。重要なのは「24時間起動できる場所」より先に「秘密情報を分けられる場所」「ログを確認できる場所」「再起動手順がある場所」です。Claude Codeに依頼するときは、デプロイ先名だけでなく、環境変数の登録、ヘルスチェック、再起動、ログ確認の手順もREADMEに書かせます。

収益導線としては、Botを作る記事で終わらせず、読者の次の行動を分けます。個人で再利用できるテンプレートやチェックリストが必要なら ClaudeCodeLabの製品 を見てもらいます。チームでDiscordサポート、Claude Code運用、権限設計、レビュー基準まで整えるなら 研修・相談 へ進めるのが自然です。関連する内部記事として、Botのコード品質には Claude Codeでコードレビューを仕組み化する、運用文書には CLAUDE.mdテンプレート もつなげておくと、記事単体で終わりません。

実際に試した結果

この記事のコードを小さな検証サーバーで動かしたところ、最も効いたのはAI回答そのものではなく、受付の型を /support に固定したことでした。自由投稿では「動きません」だけで終わる相談が多くなりますが、コマンドに summaryseveritycontext を持たせると、最初の返信で聞き返す回数が減ります。一方で、Bot token、global command、mention、権限は油断するとすぐ事故になるため、Claude Codeには実装だけでなく、.env.example、権限表、失敗時の返答、デプロイ前チェックリストまで同時に作らせるのが現実的でした。

#Claude Code #Discord Bot #discord.js #チャットボット #コミュニティ
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。