スラッシュコマンド

/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 })
  }
})

応答パターン

長い処理は 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小文字 + 数字 + アンダースコアのみ