【ESModule】【discord.js v14】最小構成のSlashコマンドボット作成

## まえがき

今あるdiscord.js最新版、ESModuleでSlashコマンドボットを作る記事がなかったので、今回記事にすることにしました。（geminiにESModuleでって言ってもrequireが外れなかった）<br>

この記事でできるrepository置いときます。めんどくさい方はこれをcloneで改良でいいと思います。
[コマンドボットv0](https://github.com/nanyanen87/tensor-bot-v2/tree/feature/version_1)<br>
３部構成で最終的にvpsに置いて、systemdで管理するとこまでやるつもりです。
1. 最小構成
1. 機能拡張
1. vpsに置いてbotとして普段使いできるように。みたいな感じで。<br>

**この記事の目的**

  * Discord.js v14を使って、シンプルなSlashコマンドボットを作成する
  * とにかく動くものを速く作る。
  * その後最低限の拡張性

**対象読者**

  * slashCommandBotを拡張しやすく作って行きたい方
  * とにかくはやくボットを動かしたい方。


**環境**

  * Discord.js: v14
  * Node.js: 23.3


## ボットを作成

* [discord developpers](https://discord.com/developers/applications)　から新規アプリ作成。
* botタブに飛びreset tokenでtoken入手<br>
 ![スクリーンショット 2024-11-28 225211.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/eec52c3e-fc65-fb3c-26d2-897bb849a41b.png)<br>

* OAuth2タブclient_id 入手<br>
![スクリーンショット 2024-11-28 225317.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/b12b059e-fb10-0048-49eb-c525529ec508.png)<br>

* そのまま下に行ってOauth2 URL generataorで必要なscopeとpermissionをcheckしurlをコピーしておく<br>
![スクリーンショット 2024-11-28 225336.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/4642e191-0141-4fe0-3848-e4725a975e9a.png)
![スクリーンショット 2024-11-28 225355.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/6fa4871e-8fb3-735d-0139-3feb9d95df41.png)

　token,client_id,auth_urlはメモって置きましょう。

## とにかく動け！

### 事前準備

```bash
# プロジェクトディレクトリを作成
mkdir discord-bot
cd discord-bot

# npmで初期化
npm init -y

# 必要なmoduleをinstall 
npm install discord.js　dotenv

# 必要なファイル作成
touch index.js deploy-command.js .env
```

### ファイル中身
参照[公式ドキュメント](https://discord.js.org/docs/packages/discord.js/14.16.3)

```deploy-command.js
import { REST, Routes } from 'discord.js';
import dotenv from "dotenv";
dotenv.config();
const TOKEN = process.env.BOT_TOKEN;
const CLIENT_ID = process.env.APP_CLIENT_ID;

// コマンドの作成
const commands = [
  {
    name: 'hogehoge', // pingだとデフォルトで登録されているので敢えて変えてます。
    description: 'fugafuga!',
  },
];


// deployの処理
const rest = new REST({ version: '10' }).setToken(TOKEN);

try {
  console.log('Started refreshing application (/) commands.');

  await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands });

  console.log('Successfully reloaded application (/) commands.');
} catch (error) {
  console.error(error);
}
```

```index.js
import dotenv from "dotenv";
dotenv.config();
const TOKEN = process.env.BOT_TOKEN;
import { Client, GatewayIntentBits, Events } from 'discord.js';
const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.on('ready', () => {
  console.log(`Logged in as ${client.user.tag}!`);
});

// interactionがあったときの処理
client.on(Events.InteractionCreate, async interaction => {
  if (!interaction.isChatInputCommand()) return;

  // 入力されたcommandNameがhogehogeならpiyopiyo!リプライを返す
  if (interaction.commandName === 'hogehoge') {
    await interaction.reply('piyopiyo!');
  }
});

client.login(TOKEN);
```
```.env
BOT_TOKEN=<あなたのbot_token>
APP_CLIENT_ID=<あなたの client_id>
```

```package.json
{
	"type": "module", ← これを追加
	"dependencies": {
		"@discordjs/rest": "^2.4.0",
		"discord.js": "^14.16.3",
		"dotenv": "^16.4.5"
	}
}
```

### 動くことを確かめよう
discord.jsが独特なのか他のボットもそうなのかはわかりませんが、手順が少しややこしいです。<br>

* まずコマンドをデプロイ
```bash
node deploy-command.js
```
* 次にbotをサーバーに登録
 auth url踏むとこういうのが出てきます。自分のサーバーに登録しましょう
    ![スクリーンショット 2024-11-29 004942.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/2a4adc20-c008-5006-6359-1f38175da2d7.png)

* commadbot起動
```bash
node index.js
```
commandbotを起動したら```/hogehoge```を打ってみましょう。```piyopiyo!```と返ってきたら成功です。

## コマンドを追加しよう
すごく単純なボットならこの構成でいいかもしれませんが、複雑なボットを作りたいならもう少し構成を考える必要があります。<br>コマンドをmodule化し、execute関数を追加しカプセル化しましょう。
### commandディレクトリ作成、ファイル作成
```bash
mkdir commands
touch commands/start.js
```
```start.js
import { SlashCommandBuilder } from 'discord.js';

export const data = new SlashCommandBuilder()
    .setName('start')
    .setDescription('サーバーを起動します。');
export async function execute(interaction) {
    console.log('stop command executed');
    // ゆくゆくはapiを叩く
    // なんか適当な処理
    await interaction.reply(`This command was run by ${interaction.user.username}, who joined on ${interaction.member.joinedAt}.`);
}

```
### deploy-command.jsの修正

```deploy-command.js
// 追加のimport
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// コマンドの作成
const commands = [];

// commandsディレクトリからコマンドファイル読み込み　*__dirnameはESModulesでは使用不可
const __dirname = import.meta.dirname  // 現在のファイルがあるディレクトリ
const commandsFolderPath = path.join(__dirname, 'commands'); // commandsディレクトリのパス
const commandFiles = fs.readdirSync(commandsFolderPath);

// 各ファイルからdeployのためのjsonデータを作成
for (const file of commandFiles) {
    const filePath = path.join(commandsFolderPath, file);
    const exported = await import(filePath);
    if ('data' in exported && 'execute' in exported) {
        const commandJson = exported.data.toJSON();
        commands.push(commandJson);
    } else {
        console.log(`[WARNING] ${filePath} には "data" または "execute" プロパティがありません。`);
    }
}

// deployの処理...

```
### index.jsの修正
```index.js
// 追加のimport
import fs from 'fs';
import { Collection } from 'discord.js';
// コマンドの読み込み、これでexecuteが実行できるようにする
client.commands = new Collection()
const commandsPath = './commands';
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
    const exported = await import(`./commands/${file}`);
    if ('data' in exported && 'execute' in exported) {
        client.commands.set(exported.data.name, exported);
    } else {
        console.log(`[WARNING] ${file} には "data" または "execute" プロパティがありません。`);
    }
}

// interactionがあったときの処理
client.on(Events.InteractionCreate, async interaction => {
    if (!interaction.isChatInputCommand()) return; // コマンド以外のイベントは無視する
    const command = interaction.client.commands.get(interaction.commandName);
    try {
        await command.execute(interaction);
    } catch (error) {
        console.error(error);
        if (interaction.replied || interaction.deferred) {
            await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true });
        } else {
            await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true });
        }
    }
});

```
### 再度ボットを更新
また最初の手順通り```node deploy-command.js```、auth urlを踏んでbot認証、```node index.js```でbot起動、すれば更新された```/start```コマンドが叩けるようになります。<br>
deployの手順が面倒だなと感じかもしれませんが、ボットを起動し直すだけいいのでめっちゃ便利だと思います。<br>
デプロイをindex.jsに組み込んだり自動化したら手間は省けますが、連続でデプロイしすぎると更新おくれるっぽいので個別にやるのが無難です。

#### 次回予告


次回の記事では、Timer機能の追加、セレクター追加をやります。ついでにinteractionもhandlerで管理し、拡張しやすい構造にします。
次の記事->[【TensorDock】Discord Botで外部サーバーを制御する仕組みを作る](https://qiita.com/nani/items/2a054fb9c600585f80e0)