Discord.pyからdiscord.js,v13にリファクタリング

# Discord.pyの開発終了
2021年8月28日、Discord APIラッパーのひとつであるDiscord.pyの開発終了がアナウンスされました。
[the_future_of_dpy.md - GitHub](https://gist.github.com/Rapptz/4a2f62751b9600a31a0d3c78100287f1)

開発終了したソフトウェアを使い続けることは、リスクの面から見ても推奨されません。
当初の予定では、同じPython製のAPIラッパーである[Pycord](https://github.com/Pycord-Development/pycord)を利用してDiscord.pyからのリファクタリングを行う予定でした。
ですがEmbedやスラッシュコマンドの実装が容易であり、人口が多いということで[Discord.js](https://discord.js.org)へのリファクタリング作業を行いました。[^1]

なお、この記事は[GitHubリポジトリ](https://github.com/Honahuku/aws_discord_bot)にコードが載っています。
よろしければstarを頂けると開発の励みになります！

https://github.com/Honahuku/aws_discord_bot

また、実装途中でわからない部分があれば私が参考にしたページを閲覧すると解決するかもしれません。
[#参考記事](#参考記事)

[^1]: ちょうど.jsを始めたところだったので、勉強がてらという理由もあります

# Bot詳細
私の管理するDiscordサーバーでは、Minecraftをプレイしている人が多くいます。
このMinecraftのマルチサーバーをEC2でホストしているのですが、誰もログインしていないときなどはこまめに停止し料金を抑えたいという需要がありました。
そこで、[AWS CLI](https://aws.amazon.com/jp/cli/)というCLIツールを利用し、Discord内から簡単にEC2インスタンスの起動や停止を行います。

リファクタリング前の旧Bot↓
[Honahuku/EC2電源管理くん](https://github.com/Honahuku/EC2-power-management-bot)

リファクタリング後↓
[Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot)

# 環境設定
このBotではNode.jsとシェルスクリプトを利用しています。
環境は以下の通り

```
Ubuntu 20.04
Discord.js v13
Node.js v16
```

## ソフトウェアのインストール
nodeなど実行に必要なソフトウェアのインストールを行います
nコマンドを利用し、複数バージョンの切り替えを可能にします。ここではltsのnodeを導入します。

```sh
sudo apt update
sudo apt upgrade -y
sudo apt install nodejs npm screen
sudo npm install n -g
sudo n lts
node -v
```

![node-v.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/593714/c5fd1c35-46a4-80a0-62d8-8d3762c49392.png)


## 作業用ディレクトリの作成
/varに作業用ディレクトリを設定します。

```sh
mkdir /var/aws_discord_bot
cd /var/aws_discord_bot
```

## モジュールのインストール

```sh
sudo npm install discord.js # Discord.js
sudo npm install dotenv --save # 環境変数読み込み用
sudo npm install --global eslint # JSの構文エラーチェック用
```

## Linterのセットアップ
jsの構文エラーを発見してくれるLinterをセットアップします。

```sh
sudo npm install --global eslint
```

### vscode拡張機能のインストール

VScodeを利用している場合は、Linter用VScode拡張機能のインストールを推奨します。
他のエディターを利用している場合は適宜対応してください。

[ESLint - Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)

### ESLintルールの設定
[Discord.jsのユーザーガイド](https://guide.discordjs-japan.org/preparations/setting-up-a-linter.html#%E3%82%B3%E3%83%BC%E3%83%88%E3%82%99%E3%82%A8%E3%83%86%E3%82%99%E3%82%A3%E3%82%BF%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)を参考にLinterのルールを設定します。

```sh
vim ./.eslintrc.json
```

```./.eslintrc.json
{
    "extends": "eslint:recommended",
    "env": {
        "node": true,
        "es6": true
    },
    "parserOptions": {
        "ecmaVersion": 2019
    },
    "rules": {
        "brace-style": ["error", "stroustrup", { "allowSingleLine": true }],
        "comma-dangle": ["error", "always-multiline"],
        "comma-spacing": "error",
        "comma-style": "error",
        "curly": ["error", "multi-line", "consistent"],
        "dot-location": ["error", "property"],
        "handle-callback-err": "off",
        "indent": ["error", "tab"],
        "max-nested-callbacks": ["error", { "max": 4 }],
        "max-statements-per-line": ["error", { "max": 2 }],
        "no-console": "off",
        "no-empty-function": "error",
        "no-floating-decimal": "error",
        "no-inline-comments": "error",
        "no-lonely-if": "error",
        "no-multi-spaces": "error",
        "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }],
        "no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }],
        "no-trailing-spaces": ["error"],
        "no-var": "error",
        "object-curly-spacing": ["error", "always"],
        "prefer-const": "error",
        "quotes": ["error", "single"],
        "semi": ["error", "always"],
        "semi-spacing": ["error", {"after": true, "before": false}],
        "semi-style": ["error", "last"],
        "no-extra-semi": "error",
        "no-unexpected-multiline": "error",
        "no-unreachable": "error",
        "space-before-blocks": "error",
        "space-before-function-paren": ["error", {
            "anonymous": "never",
            "named": "never",
            "asyncArrow": "always"
        }],
        "space-in-parens": "error",
        "space-infix-ops": "error",
        "space-unary-ops": "error",
        "spaced-comment": "error",
        "yoda": "error"
    }
}

```
# Botをサーバーに追加
## botトークンの発行
以前から使っているbotをリファクタリングするのでスキップします。
1から発行したい場合は[こちら](https://guide.discordjs-japan.org/preparations/setting-up-a-bot-application.html#creating-your-bot)を参照してください。

## サーバーIDの取得
※サーバーの管理者権限が必要です。
discordを起動し、botを導入しているサーバーのアイコンを右クリック
![サーバーアイコン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/593714/f0ca78dd-d95f-b69b-3010-df2c55ed9104.png)

表示されるメニューから`IDをコピー`を選択します。
![IDをコピー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/593714/77083825-24b3-cf3e-90a5-bc36336a36cc.png)

# envファイルの作成
さきほど取得したアクセストークンとサーバーIDを格納する`.env`ファイルを作成します。

```sh
vim .env
```

```./.env
SERVER_TOKEN="your_server_id"
DISCORD_TOKEN="Your_Discord_Bot_Token"
DEV1_INSTANCE_ID="i-00000000000000000"
DEV2_INSTANCE_ID="i-00000000000000000"
DEV3_INSTANCE_ID="i-00000000000000000"
```

# サンプルプログラム
サンプルプログラムを飛ばして実際の実装を行う場合は[#aws_cliのセットアップ](#aws_cliのセットアップ)までスキップしてください。

## スラッシュコマンド
Discordの方針転換に伴い、Discord Botはスラッシュコマンドを利用してBotを実装する必要があります。
本格的な実装を行う前にサンプルプログラムとして簡単なping-pong Botでスラッシュコマンドを実装します。

ping-pong Botではユーザーの`ping`というメッセージに反応し`Pong!`を返信します。

```sh
vim index.js 
```

```./index.js 
const { Client, Intents } = require('discord.js');
const dotenv = require('dotenv');

dotenv.config();

const client = new Client({ intents: [Intents.FLAGS.GUILDS] });

client.once("ready", async () => {
    const data = [{
        name: "ping",
        description: "Replies with Pong!",
    }];
    await client.application.commands.set(data, process.env.SERVER_TOKEN);
    console.log("Ready!");
});

client.on("interactionCreate", async (interaction) => {
    if (!interaction.isCommand()) {
        return;
    }
    if (interaction.commandName === 'ping') {
        await interaction.reply('Pong！');
    }
});

client.login(process.env.DISCORD_TOKEN);
```

pingというスラッシュコマンドにpongと反応がありました。
![](./img/ping-pong応答.png)![ping-pong応答.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/593714/6301f4e1-3cd1-a2b7-9b30-279e91c2f867.png)


## フレームワーク作成

ファイル構成を以下のように変更します。これにより各コマンドを別のファイルに分けることができます。
`index.js`から`ping.js`というモジュールを作成します。
この`ping.js`を格納するフォルダーをカレントディレクトリに新たに作成してください。

```sh
mkdir ./commands
```

```js:index.js
const fs = require('fs')
const { Client, Intents } = require('discord.js');
const dotenv = require('dotenv');

dotenv.config();

const client = new Client({ intents: [Intents.FLAGS.GUILDS] });

const commands = {}
const commandFiles = fs.readdirSync('./commands').filter(file => file.endsWith('.js'))

for (const file of commandFiles) {
    const command = require(`./commands/${file}`);
    commands[command.data.name] = command
}

client.once("ready", async () => {
    const data = []
    for (const commandName in commands) {
        data.push(commands[commandName].data)
    }
    await client.application.commands.set(data, process.env.SERVER_TOKEN);
    console.log("Ready!");
});

client.on("interactionCreate", async (interaction) => {
    if (!interaction.isCommand()) {
        return;
    }
    const command = commands[interaction.commandName];
    try {
        await command.execute(interaction);
    } catch (error) {
        console.error(error);
        await interaction.reply({
            content: 'There was an error while executing this command!',
            ephemeral: true,
        })
    }
});

client.login(process.env.DISCORD_TOKEN);
```

```js:./commands/ping.js
module.exports = {
    data: {
        name: "ping",
        description: "Replies with Pong!",
    },
    async execute(interaction) {
        await interaction.reply('Pong!');
    }
}
```

## コマンドの選択肢を追加

コマンドの選択肢を追加する場合のサンプルを以下に示します。

```js
module.exports = {
    data: {
        name: "start",
        description: "サーバーを起動します",
        options: [{
            type: "STRING",
            name: "server",
            description: "起動するサーバーを入力してください",
            required: true,
            choices: [
                { name: "Dev1", value: "dev1" },
                { name: "Dev2", value: "dev2" },
                { name: "Dev3", value: "dev3" },
            ]
        }],
    },
```

# AWS_CLIのセットアップ
discord.jsの動作にも慣れたところでAWS CLIをインストールします。

```sh
sudo apt  install awscli
```

## EC2管理用ポリシーの作成
今回操作するマシンは私のオンプレ環境にあるため、AWS CLIをアクセスキーで制御します。
意図しない動作やリスク軽減のため、専用のユーザーを作成します。
以下の権限を許可したグループを作成、新規ユーザーを作成しそのグループに所属させます。

![](./img/ポリシービジュアルエディター.png)![ポリシービジュアルエディター.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/593714/63d6aed2-ddba-3a7d-f815-26b7555b8260.png)

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ec2:RebootInstances",
                "ec2:StartInstances",
                "ec2:StopInstances"
            ],
            "Resource": "arn:aws:ec2:*:your_account_id:instance/*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "ec2:DescribeInstanceStatus"
            ],
            "Resource": "*"
        }
    ]
}
```

## aws configure
先ほど作成したユーザーのcredentialをもとにAWS CLIコマンドの設定を行います。

```
aws configure --profile ap-northeast-1
AWS Access Key ID [None]: your_Access_Key
AWS Secret Access Key [None]: your_Secret_Access_Key
Default region name [None]: ap-northeast-1
Default output format [None]: 
```

#シェルスクリプトの作成
AWS CLIコマンドでEC2インスタンスを操作するシェルスクリプトを作成します。
カレントディレクトリにshフォルダーを新規作成します。

## ステータスの取得
```sh
vim ./sh/info.sh
```

[aws_discord_bot/info.sh at master · Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot/blob/master/sh/info.sh)

```sh:./sh/info.sh
#!/bin/bash

# 変数$1はこのスクリプトの第1引数
# $1の中身がある場合
if [ -n "$1" ]; then

    # 変数responseにAWSコマンドの結果を格納
    response=$(aws ec2 --profile ap-northeast-1 describe-instances --instance-id $1 --query Reservations[*].Instances[*].State.Name --output text)
    
    # AWSコマンドからの応答に中身がある場合
    if [ -n "$response" ]; then
    echo $response

    # AWSコマンドから文字列が返って来なかった場合
    elif [ -z "$STRING" ]; then
    echo "null"
    fi

# $1の中身が無い場合
elif [ -z "$1" ]; then
  echo "Value has not been entered"
fi
```

## パブリックIPの取得

```sh
vim ./sh/address.sh
```

[aws_discord_bot/address.sh at master · Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot/blob/master/sh/address.sh)

```sh:./sh/address.sh
#!/bin/bash

# 変数$1はこのスクリプトの第1引数
# $1の中身がある場合
if [ -n "$1" ]; then

    # 変数responseにAWSコマンドの結果を格納
    response=$(aws ec2 --profile ap-northeast-1 describe-instances --instance-id $1 --query 'Reservations[*].Instances[*].PublicIpAddress' --output text)
    
    # AWSコマンドからの応答に中身がある場合
    if [ -n "$response" ]; then
    echo $response

    # AWSコマンドから文字列が返って来なかった場合
    elif [ -z "$STRING" ]; then
    echo "null"
    fi

# $1の中身が無い場合
elif [ -z "$1" ]; then
  echo "Value has not been entered"
fi
```

## インスタンス起動

```sh
vim ./sh/start.sh
```

[aws_discord_bot/start.sh at master · Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot/blob/master/sh/start.sh)

```sh:./sh/start.sh
#!/bin/bash

# 変数$1はこのスクリプトの第1引数
# $1の中身がある場合
if [ -n "$1" ]; then

    # 変数responseにAWSコマンドの結果を格納
    response=$(aws ec2 --profile ap-northeast-1 start-instances --instance-ids $1 --output text)
    
    # AWSコマンドからの応答に中身がある場合
    if [ -n "$response" ]; then
    echo $response
    
    # AWSコマンドから文字列が返って来なかった場合
    elif [ -z "$STRING" ]; then
    echo "null"
    fi

# $1の中身が無い場合
elif [ -z "$1" ]; then
  echo "Value has not been entered"
fi
```

## インスタンス停止

```sh
vim ./sh/stop.sh
```

[aws_discord_bot/stop.sh at master · Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot/blob/master/sh/stop.sh)

```sh:./sh/stop.sh
#!/bin/bash

# 変数$1はこのスクリプトの第1引数
# $1の中身がある場合
if [ -n "$1" ]; then

    # 変数responseにAWSコマンドの結果を格納
    response=$(aws ec2 --profile ap-northeast-1 stop-instances --instance-ids $1 --output text)
    
    # AWSコマンドからの応答に中身がある場合
    if [ -n "$response" ]; then
    echo $response

    # AWSコマンドから文字列が返って来なかった場合
    elif [ -z "$STRING" ]; then
    echo "null"
    fi

# $1の中身が無い場合
elif [ -z "$1" ]; then
  echo "Value has not been entered"
fi
```

# commandsの実装
さきほど作成したcommandsフォルダー内のファイルをすべて削除し、以下のプログラムを実装します。

```sh
rm ./commands/*
```
## index.js
```sh
vim ./index.js
```

[aws_discord_bot/index.js at master · Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot/blob/master/index.js)

```js:./index.js
const fs = require('fs');
const { Client, Intents } = require('discord.js');
const dotenv = require('dotenv');

dotenv.config();

const client = new Client({ intents: [Intents.FLAGS.GUILDS] });

const commands = {};
const commandFiles = fs.readdirSync('./commands').filter(file => file.endsWith('.js'));

for (const file of commandFiles) {
	const command = require(`./commands/${file}`);
	commands[command.data.name] = command;
}

client.once('ready', async () => {
	const data = [];
	for (const commandName in commands) {
		data.push(commands[commandName].data);
	}
	await client.application.commands.set(data, process.env.SERVER_TOKEN);
	console.log('Ready!');
});

client.on('interactionCreate', async (interaction) => {
	if (!interaction.isCommand()) {
		return;
	}
	const command = commands[interaction.commandName];
	try {
		await command.execute(interaction);
	}
	catch (error) {
		console.error(error);
		await interaction.reply({
			content: 'There was an error while executing this command!',
			ephemeral: true,
		});
	}
});

client.on('ready', () => {
	setInterval(() => {
		client.user.setActivity({
			name: `${client.ws.ping}ms | AWS system`,
		});
	}, 10000);
});

client.login(process.env.DISCORD_TOKEN);
```

## help
```sh
vim ./commands/help.js
```

[aws_discord_bot/help.js at master · Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot/blob/master/commands/help.js)

```js:./commands/help.js
// embed用変数宣言
const { MessageEmbed } = require('discord.js');

// embedの定義
// https://scrapbox.io/discordjs-japan/MessageEmbed%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E5%9F%8B%E3%82%81%E8%BE%BC%E3%81%BF%E3%82%92%E9%80%81%E4%BF%A1%E3%81%99%E3%82%8B%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB
const Embed = new MessageEmbed()
	.setTitle('aws_discord_bot')
	.setDescription('これはDiscord.jsを用いてAWS CLIを操作するBotプログラムです')
	.setURL('https://github.com/Honahuku/aws_discord_bot')
	.setThumbnail('https://raw.githubusercontent.com/Honahuku/aws_discord_bot/master/img/aws_discord_bot_ico.png')
	.setColor('#0099ff')
	.addField('利用可能なコマンド', '----')
	.addField('info', 'サーバーの状態を取得します')
	.addField('start', 'サーバーを起動します')
	.addField('stop', 'サーバーを停止します')
	.addField('help', 'このヘルプページを表示します')
	.addField('----', 'エラーコード')
	.addField('"インタラクションに失敗しました"と表示される', '少し待ってから再度コマンドを実行してください');

// スラッシュコマンド用選択肢の定義
module.exports = {
	data: {
		name: 'help',
		description: 'このBotのヘルプページを表示します',
	},

	// インタラクションの発生、処理を発火
	async execute(interaction) {
		await interaction.reply({ embeds: [Embed] });
	},
};
```

## info
```sh
vim ./commands/info.js
```

[aws_discord_bot/info.js at master · Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot/blob/master/commands/info.js)

```js:./commands/info.js
// embed用変数宣言
const { MessageEmbed } = require('discord.js');

// シェルコマンドを実行するためにexecSyncの有効化
const execSync = require('child_process').execSync;

// 後から利用する変数の初期化
let status;
let address;

// スラッシュコマンド用選択肢の定義
module.exports = {
	data: {
		name: 'info',
		description: 'サーバー状態を確認します',
		options: [{
			type: 'STRING',
			name: 'server',
			description: '確認するサーバーを入力してください',
			required: true,
			choices: [
				{ name: 'dev1', value: 'dev1' },
				{ name: 'dev2', value: 'dev2' },
				{ name: 'dev3', value: 'dev3' },
			],
		}],
	},

	// インタラクションの発生、処理を発火
	async execute(interaction) {

		// embedの定義
		// https://scrapbox.io/discordjs-japan/MessageEmbed%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E5%9F%8B%E3%82%81%E8%BE%BC%E3%81%BF%E3%82%92%E9%80%81%E4%BF%A1%E3%81%99%E3%82%8B%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB
		let Embed = new MessageEmbed()
			.setColor('#0099ff')
			.setTitle('Server Info')
			.setDescription('サーバーの状態を取得しています');

		// 第2引数の値に応じて分岐
		if (interaction.options.getString('server') === 'dev1') {

			// 既定のembedを一度のみ送信
			await interaction.reply({ embeds: [Embed] });
			{
				// info.shの結果を変数statusに格納
				status = execSync(`./sh/info.sh ${process.env.DEV1_INSTANCE_ID}`).toString();

				// address.shの結果を変数addressに格納
				address = execSync(`./sh/address.sh ${process.env.DEV1_INSTANCE_ID}`).toString();
				console.log(status);

				// 送信用embedの内容編集
				Embed = new MessageEmbed()
					.setColor('#0099ff')
					.setTitle(`/ info server: ${interaction.options.getString('server')}`)
					.addField('Status', status)
					.addField('Addres', address);

				// 再定義したembedをeditReplyで編集し内容反映
				interaction.editReply({ embeds: [Embed] });
			}
		}

		// 第2引数の値に応じて分岐、以下同様
		else if (interaction.options.getString('server') === 'dev2') {
			await interaction.reply({ embeds: [Embed] });
			{
				status = execSync(`./sh/info.sh ${process.env.DEV2_INSTANCE_ID}`).toString();
				address = execSync(`./sh/address.sh ${process.env.DEV2_INSTANCE_ID}`).toString();
				console.log(status);
				Embed = new MessageEmbed()
					.setColor('#0099ff')
					.setTitle(`/ info server: ${interaction.options.getString('server')}`)
					.addField('Status', status)
					.addField('Addres', address);
				interaction.editReply({ embeds: [Embed] });
			}
		}
		else if (interaction.options.getString('server') === 'dev3') {
			await interaction.reply({ embeds: [Embed] });
			{
				status = execSync(`./sh/info.sh ${process.env.DEV3_INSTANCE_ID}`).toString();
				address = execSync(`./sh/address.sh ${process.env.DEV3_INSTANCE_ID}`).toString();
				console.log(status);
				Embed = new MessageEmbed()
					.setColor('#0099ff')
					.setTitle(`/ info server: ${interaction.options.getString('server')}`)
					.addField('Status', status)
					.addField('Addres', address);
				interaction.editReply({ embeds: [Embed] });
			}
		}

		// エラー処理
		else {
			await interaction.reply('正しいサーバー名を入力してください');
		}
	},
};
```

## start
```sh
vim ./commands/start.js
```

[aws_discord_bot/start.js at master · Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot/blob/master/commands/start.js)

```js:./commands/start.js
// embed用変数宣言
const { MessageEmbed } = require('discord.js');

// シェルコマンドを実行するためにexecSyncの有効化
const execSync = require('child_process').execSync;

// 後から利用する変数の初期化
let status;
let address;

// 非同期のdelayを定義
function delay(n) {
	return new Promise(function(resolve) {
		setTimeout(resolve, n * 1000);
	});
}

// スラッシュコマンド用選択肢の定義
module.exports = {
	data: {
		name: 'start',
		description: 'サーバーを起動します',
		options: [{
			type: 'STRING',
			name: 'server',
			description: '起動するサーバーを入力してください',
			required: true,
			choices: [
				{ name: 'dev1', value: 'dev1' },
				{ name: 'dev2', value: 'dev2' },
				{ name: 'dev3', value: 'dev3' },
			],
		}],
	},

	// インタラクションの発生、処理を発火
	async execute(interaction) {

		// embedの定義
		// https://scrapbox.io/discordjs-japan/MessageEmbed%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E5%9F%8B%E3%82%81%E8%BE%BC%E3%81%BF%E3%82%92%E9%80%81%E4%BF%A1%E3%81%99%E3%82%8B%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB
		let Embed = new MessageEmbed()
			.setColor('#0099ff')
			.setTitle('Server Start')
			.setDescription('サーバーを起動しています');

		// 第2引数の値に応じて分岐
		if (interaction.options.getString('server') === 'dev1') {

			// 既定のembedを一度のみ送信
			await interaction.reply({ embeds: [Embed] });

			// ec2インスタンスの起動処理、start.shを一度のみ実行
			execSync(`./sh/start.sh ${process.env.DEV1_INSTANCE_ID}`).toString();

			// 処理終了まで無限ループ
			block: for (; ;) {

				// info.shの結果を変数statusに格納
				status = execSync(`./sh/info.sh ${process.env.DEV1_INSTANCE_ID}`).toString();

				// address.shの結果を変数addressに格納
				address = execSync(`./sh/address.sh ${process.env.DEV1_INSTANCE_ID}`).toString();

				// 送信用embedの内容編集
				Embed = new MessageEmbed()
					.setColor('#0099ff')
					.setTitle(`/ start server: ${interaction.options.getString('server')}`)
					.addField('Status', status)
					.addField('Addres', address);

				// 再定義したembedをeditReplyで編集し内容反映
				interaction.editReply({ embeds: [Embed] });

				// statusはバッファ→文字列の変換結果が格納されている。
				// 普通に文字列比較を行うと変数statusに制御文字が入っており合致しないため、runningにtoStringを行い制御文字を追加する
				// https://qiita.com/masakielastic/items/8eb4bf4efc2310ee7baf#%E6%96%87%E5%AD%97%E5%88%97%E3%81%A8-bufferuint8array-%E3%81%AE%E7%9B%B8%E4%BA%92%E5%A4%89%E6%8F%9B
				if (status == Buffer.from('running').toString()) {

					// 条件に合致した場合にblock内の処理を終了する
					break block;
				}

				// 以下処理は条件が合致しない場合に実行される
				// awsコマンドを連続実行すると負荷がかかるためディレイをもたせる
				await delay(1);
			}
		}

		// 第2引数の値に応じて分岐、以下同様
		else if (interaction.options.getString('server') === 'dev2') {
			execSync(`./sh/start.sh ${process.env.DEV2_INSTANCE_ID}`).toString();
			await interaction.reply({ embeds: [Embed] });
			block: for (; ;) {
				status = execSync(`./sh/info.sh ${process.env.DEV2_INSTANCE_ID}`).toString();
				address = execSync(`./sh/address.sh ${process.env.DEV2_INSTANCE_ID}`).toString();
				Embed = new MessageEmbed()
					.setColor('#0099ff')
					.setTitle(`/ start server: ${interaction.options.getString('server')}`)
					.addField('Status', status)
					.addField('Addres', address);
				interaction.editReply({ embeds: [Embed] });
				if (status == Buffer.from('running').toString()) {
					break block;
				}
				await delay(1);
			}
		}
		else if (interaction.options.getString('server') === 'dev3') {
			execSync(`./sh/start.sh ${process.env.DEV3_INSTANCE_ID}`).toString();
			await interaction.reply({ embeds: [Embed] });
			block: for (; ;) {
				status = execSync(`./sh/info.sh ${process.env.DEV3_INSTANCE_ID}`).toString();
				address = execSync(`./sh/address.sh ${process.env.DEV3_INSTANCE_ID}`).toString();
				Embed = new MessageEmbed()
					.setColor('#0099ff')
					.setTitle(`/ start server: ${interaction.options.getString('server')}`)
					.addField('Status', status)
					.addField('Addres', address);
				interaction.editReply({ embeds: [Embed] });
				if (status == Buffer.from('running').toString()) {
					break block;
				}
				await delay(1);
			}
		}

		// エラー処理
		else {
			await interaction.reply('正しいサーバー名を入力してください');
		}
	},
};
```

## stop
```sh
vim ./commands/stop.js
```

[aws_discord_bot/stop.js at master · Honahuku/aws_discord_bot](https://github.com/Honahuku/aws_discord_bot/blob/master/commands/stop.js)

```js:./commands/stop.js
// embed用変数宣言
const { MessageEmbed } = require('discord.js');

// シェルコマンドを実行するためにexecSyncの有効化
const execSync = require('child_process').execSync;

// 後から利用する変数の初期化
let status;
let address;

// 非同期のdelayを定義
function delay(n) {
	return new Promise(function(resolve) {
		setTimeout(resolve, n * 1000);
	});
}

// スラッシュコマンド用選択肢の定義
module.exports = {
	data: {
		name: 'stop',
		description: 'サーバーを停止します',
		options: [{
			type: 'STRING',
			name: 'server',
			description: '停止するサーバーを入力してください',
			required: true,
			choices: [
				{ name: 'dev1', value: 'dev1' },
				{ name: 'dev2', value: 'dev2' },
				{ name: 'dev3', value: 'dev3' },
			],
		}],
	},

	// インタラクションの発生、処理を発火
	async execute(interaction) {

		// embedの定義
		// https://scrapbox.io/discordjs-japan/MessageEmbed%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E5%9F%8B%E3%82%81%E8%BE%BC%E3%81%BF%E3%82%92%E9%80%81%E4%BF%A1%E3%81%99%E3%82%8B%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB
		let Embed = new MessageEmbed()
			.setColor('#0099ff')
			.setTitle('Server Stop')
			.setDescription('サーバーを停止しています');

		// 第2引数の値に応じて分岐
		if (interaction.options.getString('server') === 'dev1') {

			// 既定のembedを一度のみ送信
			await interaction.reply({ embeds: [Embed] });

			// ec2インスタンスの停止処理、stop.shを一度のみ実行
			execSync(`./sh/stop.sh ${process.env.DEV1_INSTANCE_ID}`).toString();

			// 処理終了まで無限ループ
			block: for (; ;) {

				// info.shの結果を変数statusに格納
				status = execSync(`./sh/info.sh ${process.env.DEV1_INSTANCE_ID}`).toString();

				// address.shの結果を変数addressに格納
				address = execSync(`./sh/address.sh ${process.env.DEV1_INSTANCE_ID}`).toString();

				// 送信用embedの内容編集
				Embed = new MessageEmbed()
					.setColor('#0099ff')
					.setTitle(`/ stop server: ${interaction.options.getString('server')}`)
					.addField('Status', status)
					.addField('Addres', address);

				// 再定義したembedをeditReplyで編集し内容反映
				interaction.editReply({ embeds: [Embed] });

				// statusはバッファ→文字列の変換結果が格納されている。
				// 普通に文字列比較を行うと変数statusに制御文字が入っており合致しないため、runningにtoStringを行い制御文字を追加する
				// https://qiita.com/masakielastic/items/8eb4bf4efc2310ee7baf#%E6%96%87%E5%AD%97%E5%88%97%E3%81%A8-bufferuint8array-%E3%81%AE%E7%9B%B8%E4%BA%92%E5%A4%89%E6%8F%9B
				if (status == Buffer.from('stopped').toString()) {

					// 条件に合致した場合にblock内の処理を終了する
					break block;
				}

				// 以下処理は条件が合致しない場合に実行される
				// awsコマンドを連続実行すると負荷がかかるためディレイをもたせる
				await delay(1);
			}
		}

		// 第2引数の値に応じて分岐、以下同様
		else if (interaction.options.getString('server') === 'dev2') {
			await interaction.reply({ embeds: [Embed] });
			block: for (; ;) {
				execSync(`./sh/stop.sh ${process.env.DEV2_INSTANCE_ID}`).toString();
				status = execSync(`./sh/info.sh ${process.env.DEV2_INSTANCE_ID}`).toString();
				address = execSync(`./sh/address.sh ${process.env.DEV2_INSTANCE_ID}`).toString();
				Embed = new MessageEmbed()
					.setColor('#0099ff')
					.setTitle(`/ stop server: ${interaction.options.getString('server')}`)
					.addField('Status', status)
					.addField('Addres', address);
				interaction.editReply({ embeds: [Embed] });
				if (status == Buffer.from('stopped').toString()) {
					break block;
				}
				await delay(1);
			}
		}
		else if (interaction.options.getString('server') === 'dev3') {
			await interaction.reply({ embeds: [Embed] });
			block: for (; ;) {
				execSync(`./sh/stop.sh ${process.env.DEV3_INSTANCE_ID}`).toString();
				status = execSync(`./sh/info.sh ${process.env.DEV3_INSTANCE_ID}`).toString();
				address = execSync(`./sh/address.sh ${process.env.DEV3_INSTANCE_ID}`).toString();
				Embed = new MessageEmbed()
					.setColor('#0099ff')
					.setTitle(`/ stop server: ${interaction.options.getString('server')}`)
					.addField('Status', status)
					.addField('Addres', address);
				interaction.editReply({ embeds: [Embed] });
				if (status == Buffer.from('stopped').toString()) {
					break block;
				}
				await delay(1);
			}
		}

		// エラー処理
		else {
			await interaction.reply('正しいサーバー名を入力してください');
		}
	},
};
```

# eslintでエラーチェック
最初に設定したeslintを使ってエラーチェックを行います。
`--fix`コマンドで修正できないエラーが出ている場合は、この記事や[GitHubリポジトリ](https://github.com/Honahuku/aws_discord_bot)のコードを参考に修正してください。

```sh
eslint --fix ./index.js
eslint --fix commands/help.js
eslint --fix commands/info.js
eslint --fix commands/start.js
eslint --fix commands/stop.js
```

# systemd
毎回サーバーから`node index.js`と実行するのは面倒なのでsystemdを使ってサービス化します。
serviceファイルを作成します。

```sh
vim ./aws_discord_bot.service
```
```systemd
[Unit]
Description=aws_discord_bot
After=network.target

[Service]
Type = simple
WorkingDirectory=/var/aws_discord_bot
ExecStart=/usr/bin/screen -DmS aws-bot%i /usr/local/bin/node /var/aws_discord_bot/index.js
Restart=always

[Install]
WantedBy=multi-user.target
```

作成したserviceファイルを登録します。

```sh
sudo cp ./aws_discord_bot.service /lib/systemd/system/
sudo systemctl enable aws_discord_bot
sudo systemctl start aws_discord_bot
systemctl status aws_discord_bot
```

# 動作検証
![](./img/info_stop.png)![info_stop.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/593714/49ded650-e305-326b-d159-a1757c3ed8fa.png)

![](./img/start.png)![start.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/593714/e2740731-a789-e2b2-5fba-84f78a29dbf1.png)

![](./img/info.png)![info.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/593714/69a10e89-0dc3-1a41-ef6b-c5f330ab7cd0.png)

![](./img/stop.png)![stop.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/593714/eb0fb0d8-c57e-f4e8-5209-67c1ed6313e7.png)

これらの動作が確認できたら完成です。
お疲れ様でした。

# 参考記事
## Discord.jsユーザーガイド
[はじめに | Discord.js ガイド](https://guide.discordjs-japan.org/)

## nodeのインストール
[Ubuntu で Node の最新版/推奨版を使う (n コマンド編) - Qiita](https://qiita.com/cointoss1973/items/c000c4f84ae4b0c166b5)

## スラッシュコマンド
[discord.js でスラッシュコマンド（Slash commands）を使う - Qiita](https://qiita.com/gaato/items/55b32bc4777905ac162a)

## Embed
[Discord.jsでembed (埋め込みメッセージ) を扱う - Qiita](https://qiita.com/nedew/items/4e0c20c1a89e983a6992)

[MessageEmbedを使って埋め込みを送信するサンプル - Discord.js Japan User Group](https://scrapbox.io/discordjs-japan/MessageEmbed%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E5%9F%8B%E3%82%81%E8%BE%BC%E3%81%BF%E3%82%92%E9%80%81%E4%BF%A1%E3%81%99%E3%82%8B%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB)

### スラッシュコマンドでembed
[How to make a slash commands bot with Discord.js V13 - DEV Community](https://dev.to/kb24x7/how-to-make-a-slash-commands-bot-with-discord-js-v13-3l6k)


## AWS_CLIとIAM
[AWS EC2の起動と停止のみが可能なIAMポリシーを作成してIAMポリシーの設定方法を理解する | Tech Note Meeting](https://tech-note-meeting.com/2021/06/05/post-984/)

## execSync
[Node.jsからシェルコマンドを実行する - BppLOG](https://tkybpp.hatenablog.com/entry/2016/04/25/163246)

## delay
[JavaScript で X 秒を待つ | Delft スタック](https://www.delftstack.com/ja/howto/javascript/javascript-wait-for-x-seconds/)

## バッファーから文字列への変換
[JavaScript、Node.js で文字列とバイト列の相互変換 - Qiita](https://qiita.com/masakielastic/items/8eb4bf4efc2310ee7baf#%E6%96%87%E5%AD%97%E5%88%97%E3%81%A8-bufferuint8array-%E3%81%AE%E7%9B%B8%E4%BA%92%E5%A4%89%E6%8F%9B)