LoginSignup
4

More than 1 year has passed since last update.

posted at

updated at

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

Discord.pyの開発終了

2021年8月28日、Discord APIラッパーのひとつであるDiscord.pyの開発終了がアナウンスされました。
the_future_of_dpy.md - GitHub

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

なお、この記事はGitHubリポジトリにコードが載っています。
よろしければstarを頂けると開発の励みになります!

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

Bot詳細

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

リファクタリング前の旧Bot↓
Honahuku/EC2電源管理くん

リファクタリング後↓
Honahuku/aws_discord_bot

環境設定

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

Ubuntu 20.04
Discord.js v13
Node.js v16

ソフトウェアのインストール

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

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

作業用ディレクトリの作成

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

mkdir /var/aws_discord_bot
cd /var/aws_discord_bot

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

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

Linterのセットアップ

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

sudo npm install --global eslint

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

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

ESLint - Visual Studio Marketplace

ESLintルールの設定

Discord.jsのユーザーガイドを参考にLinterのルールを設定します。

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から発行したい場合はこちらを参照してください。

サーバーIDの取得

※サーバーの管理者権限が必要です。
discordを起動し、botを導入しているサーバーのアイコンを右クリック
サーバーアイコン.png

表示されるメニューからIDをコピーを選択します。
IDをコピー.png

envファイルの作成

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

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のセットアップまでスキップしてください。

スラッシュコマンド

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

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

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と反応がありました。
ping-pong応答.png

フレームワーク作成

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

mkdir ./commands
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);
./commands/ping.js
module.exports = {
    data: {
        name: "ping",
        description: "Replies with Pong!",
    },
    async execute(interaction) {
        await interaction.reply('Pong!');
    }
}

コマンドの選択肢を追加

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

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をインストールします。

sudo apt  install awscli

EC2管理用ポリシーの作成

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

ポリシービジュアルエディター.png

{
    "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フォルダーを新規作成します。

ステータスの取得

vim ./sh/info.sh

aws_discord_bot/info.sh at master · Honahuku/aws_discord_bot

./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の取得

vim ./sh/address.sh

aws_discord_bot/address.sh at master · Honahuku/aws_discord_bot

./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

インスタンス起動

vim ./sh/start.sh

aws_discord_bot/start.sh at master · Honahuku/aws_discord_bot

./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

インスタンス停止

vim ./sh/stop.sh

aws_discord_bot/stop.sh at master · Honahuku/aws_discord_bot

./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フォルダー内のファイルをすべて削除し、以下のプログラムを実装します。

rm ./commands/*

index.js

vim ./index.js

aws_discord_bot/index.js at master · Honahuku/aws_discord_bot

./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

vim ./commands/help.js

aws_discord_bot/help.js at master · Honahuku/aws_discord_bot

./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

vim ./commands/info.js

aws_discord_bot/info.js at master · Honahuku/aws_discord_bot

./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

vim ./commands/start.js

aws_discord_bot/start.js at master · Honahuku/aws_discord_bot

./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

vim ./commands/stop.js

aws_discord_bot/stop.js at master · Honahuku/aws_discord_bot

./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リポジトリのコードを参考に修正してください。

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ファイルを作成します。

vim ./aws_discord_bot.service
[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ファイルを登録します。

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

動作検証

info_stop.png

start.png

info.png

stop.png

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

参考記事

Discord.jsユーザーガイド

はじめに | Discord.js ガイド

nodeのインストール

Ubuntu で Node の最新版/推奨版を使う (n コマンド編) - Qiita

スラッシュコマンド

discord.js でスラッシュコマンド(Slash commands)を使う - Qiita

Embed

Discord.jsでembed (埋め込みメッセージ) を扱う - Qiita

MessageEmbedを使って埋め込みを送信するサンプル - Discord.js Japan User Group

スラッシュコマンドでembed

How to make a slash commands bot with Discord.js V13 - DEV Community

AWS_CLIとIAM

AWS EC2の起動と停止のみが可能なIAMポリシーを作成してIAMポリシーの設定方法を理解する | Tech Note Meeting

execSync

Node.jsからシェルコマンドを実行する - BppLOG

delay

JavaScript で X 秒を待つ | Delft スタック

バッファーから文字列への変換

JavaScript、Node.js で文字列とバイト列の相互変換 - Qiita


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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
4