discord.jsでスラッシュコマンドのDiscord Botを作る:登録・3秒ルール・ホスティングまで
discord.jsでスラッシュコマンドのDiscord Botをゼロから作る手順。コマンド登録、Gateway Intents、interaction応答の3秒ルール、トークン管理、ホスティングをコピペコード付きで解説。
Discordの問い合わせチャンネルに「Botが動きません」だけが届き、担当者が環境・実行コマンド・エラー内容を順番に聞き直す。受付フォームを整えたい担当者が最初につまずくのは、AI回答ではなく、この情報不足です。
そこで本記事では、/supportで必要な項目を受け取り、担当チャンネルへ整形して送るDiscord Botを作ります。入力欄にコマンドが出ない登録ミス、処理が遅いときの応答失敗、Botトークンや権限の事故まで、初心者が順番に確認できる構成です。
掲載コードはNode.js 24.14.1で構文と純粋関数をローカル検証します。実際のDiscordサーバーへの接続、メッセージ送信、運用上の成果は検証範囲に含めません。まずはテスト用guildで/supportを1件通せる状態を目標にしてください。
この記事の要点
- コマンドはコードを書くだけでは出ない。開発はguild、本番公開はglobalへ登録する。
- 重い処理は先に
deferReply()し、完了後にeditReply()する。 - スラッシュコマンドだけならGateway Intentは
Guildsで足りる。 - トークンは
.envへ分け、Bot権限はView ChannelsとSend Messagesから始める。
discord.jsとスラッシュコマンドの全体像
スラッシュコマンドは入力欄で/を打つと出るApplication Commandです。実行時のinteractionをdiscord.jsのEvents.InteractionCreateで受けます。
| コマンド | 出力 | 利用者 |
|---|---|---|
/support | 受付チャンネルへ整形した相談 | 全員 |
/faq | 選択した項目の短い回答 | 全員 |
/handoff | 担当者向け引き継ぎメモ | Manage Messages権限を持つ人 |
flowchart LR
A["利用者"] --> B["Slash Command"]
B --> C["discord.js Bot"]
C --> D["ephemeral応答"]
C --> E["担当チャンネル"]
今回はGatewayIntentBits.Guildsだけを使います。メッセージ本文を読むMessageContentは特権インテントなので追加しません。
一次情報はApplication Commands、interaction応答、discord.js 14.27.0で確認します。
コマンドは「書いただけ」では出ない:登録の仕組み
SlashCommandBuilderで定義した後、Discord APIへの登録が必要です。開発中は指定サーバーだけのguildコマンド、本番公開時は全導入先向けのglobalコマンドを使います。下のコードはDISCORD_GUILD_IDがあればRoutes.applicationGuildCommands、なければRoutes.applicationCommandsへ送ります。
rest.putは送信した配列でコマンド一覧を置き換えます。開発中にglobalへ何度も登録すると伝播待ちと不具合を切り分けにくいため、まずguildで確認してください。
先に決める:トークンと最小権限
先にDiscord Developer PortalでアプリケーションとBotユーザーを作ります。招待scopeはbotとapplications.commandsです。Administratorは付けず、受付チャンネルを見て送る権限から始めます。
| 項目 | 値 | 注意点 |
|---|---|---|
| Node.js | 24 LTS | ローカル検証は24.14.1。discord.jsのenginesは18以上 |
| scope | bot, applications.commands | スラッシュコマンド登録に必須 |
| Bot権限 | View Channels, Send Messages | 最小から始める |
DISCORD_TOKEN | Botトークン | Git・ログ・スクショに出さない |
DISCORD_CLIENT_ID | アプリID | コマンド登録に使う |
DISCORD_GUILD_ID | テスト用guild ID | 開発時だけ設定 |
SUPPORT_CHANNEL_ID | 受付チャンネルID | 送信権限を確認 |
IDはDiscordの開発者モードで取得します。秘密値は環境変数の管理、例外はエラー処理パターンも参照してください。
コピペで動くdiscord.jsスターター
ここからは受付・FAQ・引き継ぎ・エラー時のephemeral応答・mention事故の無効化を含むコードです。本記事ではNode.js 24.14.1とdiscord.js 14.27.0を使います。
まずプロジェクトを作ります。
mkdir discord-support-bot
cd discord-support-bot
npm init -y
npm install [email protected] [email protected]
mkdir src
package.json に type と起動スクリプトを足します。
{
"type": "module",
"scripts": {
"start": "node src/bot.js"
},
"dependencies": {
"discord.js": "14.27.0",
"dotenv": "17.2.3"
}
}
.env を作ります。値はDeveloper Portalと開発者モードから取ってきてください。
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。長く見えますが、やっていることは「コマンド定義 → 登録 → interactionを受けて返事」の3ブロックだけです。
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.`);
}
// --- 1. コマンド定義 ---
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")
// このコマンドは Manage Messages 権限を持つ人にだけ表示される
.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());
// --- 2. Botクライアント(Intentsは最小限)---
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (readyClient) => {
console.log(`Logged in as ${readyClient.user.tag}`);
});
// --- 3. interactionを受けて返事する ---
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: [] }, // 入力に紛れた @everyone 等を通知させない
});
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 24 LTS, 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;
}
// @everyone やロールmentionを無害化する。公開サーバーでは必須
function neutralizeMentions(value) {
return value
.replaceAll("@everyone", "@ everyone")
.replaceAll("@here", "@ here")
.replace(/<@!?(\d+)>/g, "user:$1")
.replace(/<@&(\d+)>/g, "role:$1");
}
// 既に返信済みかどうかを見て、reply と followUp を使い分ける
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);
}
// --- コマンド登録(DEPLOY_COMMANDS=true のときだけ走らせる)---
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で24系を確認してからnpm startします。最初はテスト用guildで/supportを実行し、受付チャンネルへの投稿とephemeral応答を確認します。次に/faq、最後に権限あり・なしの2アカウントで/handoffを確認してください。コマンド登録後は.envのDEPLOY_COMMANDSをfalseへ戻します。
interactionの3秒ルールとdeferReply
interactionは受信後すぐに初回応答が必要です。外部APIや生成AIを呼ぶ場合は、処理前にdeferReply()で受領を返し、完了後にeditReply()で差し替えます。制約はReceiving and Respondingを参照してください。
async function handleSlowTask(interaction) {
// まず3秒以内に「考え中」を返してトークンを延命する
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
// ここで時間のかかる処理をしてOK(外部API・DB・LLMなど)
const result = await callSomethingSlow(interaction.options.getString("query", true));
// 終わったら本当の答えに差し替える
await interaction.editReply({ content: result });
}
順番はdefer → 重い処理 → editReplyです。deferReply()を外部処理の後へ置かないでください。
Claude Codeに任せる範囲と、人が判断する範囲
Claude Codeには、コマンド定義、入力値の長さ制限、環境変数チェック、エラー時の返信、テストコード、READMEの更新を任せます。「/supportのsummaryは900文字まで」「ユーザー入力のmentionを無効化」のように、判定できる条件をプロンプトへ書くのがコツです。
人が決めるのは、誰が受付チャンネルを読めるか、どの情報を保存してよいか、どの権限をBotへ渡すか、障害時に誰がトークンを再発行するかです。Claude Codeが出した権限設定をそのまま本番へ入れず、Developer Portalとサーバーのロール設定を担当者が照合します。
コードを直す役割と、運用リスクを引き受ける役割を混ぜないでください。Botが個人情報や契約情報を扱うなら、保存期間と削除手順も人が先に決めます。
3つのUse case
Use case 1: サポート受付をそろえる
入力: /support summary:ログイン不可 severity:high context:npm startで失敗。
出力: 実行者、緊急度、要約、補足を受付チャンネルへ投稿し、本人にはephemeralで完了を返します。
人の確認: 個人情報とトークンの混入を見て、担当と優先度を決めます。Botは原因を断定しません。
Use case 2: FAQから正しい手順へ送る
入力: 利用者が/faq topic:setupを選びます。
出力: Node.js、招待scope、起動コマンドを返し、Claude Codeのはじめ方へつなげます。
人の確認: ライブラリ更新時に回答とリンクを見直します。
Use case 3: モデレーターの引き継ぎを残す
入力: 担当者が/handoff target:@user note:決済確認待ちを送ります。
出力: 対象者、記入者、引き継ぎ文を投稿し、権限がなければ拒否理由を返します。
人の確認: 決済情報や不要な個人情報がないかを見て、対応完了を判断します。
ROIは仮定で計算します。1日20件で聞き返しが1件1分減るなら、20件 × 1分 × 20営業日 = 月400分です。成果保証ではないため、導入前後4週間の追加質問回数とBot保守時間を記録します。
Pitfall: 本番前に潰す5つの事故
- トークン露出。原因は
.envの誤コミットです。漏れたら即rotateし、.env.exampleにはダミー値だけ置きます。 - 登録先違い。原因はguild IDを残した本番公開です。開発はguild、公開時はglobalへ切り替えます。
- 応答漏れ。原因は成功時しか
replyしない実装です。例外もsafeReplyへ通します。 - mention事故。原因は入力の再投稿です。
allowedMentions: { parse: [] }とneutralizeMentionsを併用します。 - 広すぎる権限。Administratorを避け、View ChannelsとSend Messagesから始めます。
コピペで使えるプロンプト
既存リポジトリへ追加する場合は、Claude Codeに次のように渡します。権限と検証範囲を先に固定すると、見栄えだけのBotになりません。
Node.js 24 LTS、discord.js 14.27.0でDiscordのサポート受付Botを作ってください。
要件:
- /support は summary、severity、context を受け取る
- /faq は setup、permissions、rollout の固定回答を返す
- /handoff は Manage Messages 権限を持つメンバーだけ実行できる
- ユーザー入力は @everyone、@here、user/role mention を無害化する
- allowedMentions は parse: [] にする
- 外部APIを呼ぶ経路は deferReply の後に処理し、editReply で返す
- DISCORD_TOKEN、DISCORD_CLIENT_ID、DISCORD_GUILD_ID、SUPPORT_CHANNEL_ID を.envから読む
- 秘密値をログ、テスト、.env.exampleへ書かない
- guild登録とglobal登録を環境変数で切り替える
- 純粋関数の単体テスト、構文確認、起動手順、権限表を追加する
実装後、変更ファイル、実行したテスト、未検証項目を分けて報告してください。
実Discordへの送信は、私がテスト用guildとトークンを用意するまで実行しないでください。
ホスティング:どこで24時間動かすか
Botはプロセスを止めると反応しません。Railway、Render、VPS、社内サーバーなどから、環境変数を分離できる、ログを読める、再起動手順を決められる場所を選びます。無料枠や料金は変わるため、公開前に各社の現行条件を確認してください。
READMEには環境変数の登録、ヘルスチェック、再起動、ログ確認、トークンrotateの担当者を書きます。コードの変更手順はコードレビューの仕組み化、引き継ぎルールはCLAUDE.mdテンプレートへ分けると追いやすくなります。
よくある質問
Q. スラッシュコマンドが出ません。
DEPLOY_COMMANDS=trueで登録したか、DISCORD_GUILD_IDが対象guildかを確認します。開発中はguild、本番公開時だけglobalへ切り替えます。
Q. 「アプリケーションが応答しませんでした」と赤く出ます。
重い処理の前にdeferReply()し、完了後にeditReply()します。例外もsafeReplyへ通してください。
Q. Botトークンを誤ってGitに上げてしまいました。
Developer Portalで即rotateし、古いトークンを無効化します。次にGit履歴を処理し、.envを.gitignoreへ入れます。履歴削除だけでは漏れたトークンを止められません。
実際に試した結果
掲載したpackage.jsonはJSONとして読み込めること、bot.jsはNode.js 24.14.1で構文エラーがないことを確認しました。さらにneutralizeMentionsへ@everyone、@here、ユーザーmention、ロールmentionを渡し、通知を起こさない文字列へ変換されることをローカルfixtureで確認します。コード、公式URL、内部リンク、CTA、frontmatterの日付も自動チェックの対象です。
Developer Portalへの登録、実guildでの表示・送信、ホスティングは未検証です。運用成果は実績として述べません。公開前に権限あり・なしのアカウントで3コマンドを1件ずつ確認してください。
今日まず行う作業は、Node.js 24 LTSを確認し、テスト用guildへ限定して/supportを登録することです。設計・検証に使えるテンプレートをまとめて確認する場合は、教材一覧へ進んでください。
次に読む記事
Slack BotをNodeで作る:Boltでスラッシュコマンド・通知・署名検証まで通す
Bolt(Node)でSlack Botを作る手順。権限スコープ、イベント購読、スラッシュコマンド、通知、署名検証までコピペで動くコード付きで解説。
Node.jsでWebスクレイピング:fetch+cheerioで取得し、Playwrightで動的ページに対応する
Node.jsでのWebスクレイピング実務。fetchとcheerioで取得・解析し、動的ページはPlaywright。robots.txt確認とマナー、ブロックや構造変化への備えまで。
ResendとReact Emailで作るトランザクションメール実装:登録・通知・再設定を確実に届ける
アプリからのメール送信をResendとReact Emailで実装。登録完了・通知・パスワード再設定の送り分け、配信失敗の再試行、SPF/DKIM/DMARCの触りまで、写して動くコードで解説。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。