スラッシュコマンド
/ping 形式のコマンド。Discord に事前登録しておき、ユーザーが入力すると Discord から
Bot に InteractionCreate イベントが届く。
2 つのスコープ
| スコープ | 反映時間 | 用途 |
|---|---|---|
| Guild(特定サーバー) | 即時 | 開発・テスト |
| Global(全サーバー) | 最大1時間 | 本番 |
開発中は Guild、本番は Globalに切り替えるのが定石。
登録スクリプト
コマンドの定義(名前・説明・オプション)を Discord に送る。Bot 起動とは別の独立スクリプト。
src/deploy-commands.ts
import "dotenv/config"
import { REST, Routes, SlashCommandBuilder } from "discord.js"
const commands = [
new SlashCommandBuilder()
.setName("ping")
.setDescription("Pong を返す"),
new SlashCommandBuilder()
.setName("echo")
.setDescription("入力をそのまま返す")
.addStringOption((opt) =>
opt.setName("text").setDescription("送る文字列").setRequired(true),
),
].map((c) => c.toJSON())
const rest = new REST().setToken(process.env.DISCORD_TOKEN!)
;(async () => {
// Guild 登録(開発)
await rest.put(
Routes.applicationGuildCommands(
process.env.DISCORD_APP_ID!,
process.env.DISCORD_GUILD_ID!,
),
{ body: commands },
)
// Global 登録(本番)
// await rest.put(Routes.applicationCommands(APP_ID), { body: commands })
console.log("Deployed!")
})()
実行: npx tsx src/deploy-commands.ts
ハンドラ
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return
if (interaction.commandName === "ping") {
await interaction.reply("Pong!")
}
if (interaction.commandName === "echo") {
const text = interaction.options.getString("text", true)
await interaction.reply(text)
}
})
オプションの型
| メソッド | 型 | 取得 |
|---|---|---|
addStringOption | 文字列 | options.getString(name) |
addIntegerOption | 整数 | options.getInteger(name) |
addNumberOption | 小数 | options.getNumber(name) |
addBooleanOption | 真偽 | options.getBoolean(name) |
addUserOption | ユーザー | options.getUser(name) |
addChannelOption | チャンネル | options.getChannel(name) |
addRoleOption | ロール | options.getRole(name) |
addMentionableOption | メンション可 | options.getMentionable(name) |
addAttachmentOption | 添付ファイル | options.getAttachment(name) |
選択肢(choices)
.addStringOption((opt) =>
opt.setName("color")
.setDescription("色を選ぶ")
.setRequired(true)
.addChoices(
{ name: "赤", value: "red" },
{ name: "青", value: "blue" },
{ name: "緑", value: "green" },
),
)
autocomplete(動的選択肢)
.addStringOption((opt) =>
opt.setName("user").setDescription("検索").setAutocomplete(true),
)
// ハンドラ
if (interaction.isAutocomplete()) {
const focused = interaction.options.getFocused()
const results = await searchUsers(focused)
await interaction.respond(
results.slice(0, 25).map((u) => ({ name: u.name, value: u.id })),
)
}
サブコマンド
new SlashCommandBuilder()
.setName("config")
.setDescription("設定")
.addSubcommand((sub) =>
sub.setName("get").setDescription("取得")
.addStringOption((opt) => opt.setName("key").setDescription("キー").setRequired(true)),
)
.addSubcommand((sub) =>
sub.setName("set").setDescription("設定")
.addStringOption((opt) => opt.setName("key").setRequired(true))
.addStringOption((opt) => opt.setName("value").setRequired(true)),
)
// ハンドラ
const sub = interaction.options.getSubcommand()
if (sub === "get") { ... }
if (sub === "set") { ... }
ファイル分割
実用ではコマンドごとにファイルを分けて動的にロード。
src/commands/ping.ts
import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js"
export const data = new SlashCommandBuilder()
.setName("ping")
.setDescription("Pong を返す")
export async function execute(interaction: ChatInputCommandInteraction) {
await interaction.reply("Pong!")
}
src/index.ts(読み込み)
import { readdirSync } from "node:fs"
import { Collection } from "discord.js"
const commands = new Collection<string, any>()
const files = readdirSync("./src/commands").filter((f) => f.endsWith(".ts"))
for (const file of files) {
const mod = await import(`./commands/${file}`)
commands.set(mod.data.name, mod)
}
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return
const cmd = commands.get(interaction.commandName)
if (!cmd) return
try { await cmd.execute(interaction) }
catch (e) {
console.error(e)
if (interaction.replied) await interaction.followUp({ content: "エラー", ephemeral: true })
else await interaction.reply({ content: "エラー", ephemeral: true })
}
})
応答パターン
interaction.reply(...)— 通常返信(3秒以内)interaction.deferReply()— 「考え中」表示。後でeditReplyinteraction.editReply(...)— 既存応答を編集interaction.followUp(...)— 追加メッセージinteraction.deleteReply()— 削除
長い処理は defer
await interaction.deferReply() // 「Bot is thinking...」
const result = await heavyTask() // 何秒かかってもOK(最大15分)
await interaction.editReply(result)
ephemeral(本人だけに見える)
await interaction.reply({ content: "あなただけに見える", ephemeral: true })
権限制限
import { PermissionFlagsBits } from "discord.js"
new SlashCommandBuilder()
.setName("ban")
.setDescription("Ban")
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setDMPermission(false)
コンテキストメニュー
メッセージ右クリック・ユーザー右クリックで出るコンテキストコマンド。
import { ContextMenuCommandBuilder, ApplicationCommandType } from "discord.js"
new ContextMenuCommandBuilder()
.setName("Translate")
.setType(ApplicationCommandType.Message)
// ハンドラ
if (interaction.isMessageContextMenuCommand()) {
const target = interaction.targetMessage
// ...
}
ローカライズ
new SlashCommandBuilder()
.setName("ping")
.setDescription("Returns pong")
.setDescriptionLocalizations({
"ja": "Pong を返す",
"ko": "Pong을 반환",
})
よくある失敗
✗ 3秒以内に応答しないと "interaction failed"。長い処理は deferReply
✗ Global 登録は最大1時間反映に時間。開発は Guild 登録
✗ コマンド変更後は deploy-commands.ts 再実行が必要
✗ setName は小文字 + 数字 + アンダースコアのみ