マイクラサーバーをDiscordで遠隔起動

<!-- qiita.com -->
# マイクラサーバーをDiscordで遠隔起動

## はじめに

外出先からマイクラサーバーを起動したいと思ったことはありませんか？
Discord.jsを使ってDiscordのBotを作成し、マイクラサーバーをDiscordから起動する方法を紹介します。

## 事前準備

node.jsがインストールされていることが前提です。

### Discord Botの作成

1. [Discord Developer Portal](https://discord.com/developers/applications)にアクセスし、新しいアプリケーションを作成します。
2. Botを作成し、トークンをコピーします。
3. Botをサーバーに追加します。

## Node.jsからMinecraftサーバーを起動してみる

マイクラサーバーを子プロセスとして起動するため、`child_process`モジュールを使用します。

```javascript:index.js
const { spawn } = require('child_process');

function serverStart() {
    if (status === "online") return;
    child = spawn('bash', ['/home/jun/forge/run.sh']);// マイクラサーバーを起動するシェルスクリプト
    status = "booting";
    fs.writeFileSync('/home/jun/IWBot/serverpid', child.pid.toString(), 'utf8');// 親プロセスだけがエラーで終了した場合に子プロセスをkillするためにpidを保存
    child.stdout.on('data', (data) => {
        // bootchecker
        if (data.toString().includes('server boot checker') && status === "booting") {
            SendEmbed('サーバーが起動したよん');// サーバーが起動したことを通知
            status = "online";
        }
        logTimeDelay = new Date().getTime();
        handleLog(data);
        latestLog1000 += data.toString();
        if (latestLog1000.length > 1000) {
            latestLog1000 = latestLog1000.slice(latestLog1000.length - 1000);
        }
    });
    child.on('close', (code) => {
        console.log(`Child process exited with code ${code}.`);
        log.send(`Child process exited with code ${code}.`);
        if (status === "rebooting") {
            serverStart();
        }
        else {
            status = "offline";
        }
        fs.writeFileSync('/home/jun/IWBot/serverpid', '');
    });
}
```

かなり前に作成したコードなので、今見るととても拙いですが、基本的な流れは以下の通りです。

- 変数`status`が`online`の場合はサーバーが既に起動しているため、再起動しないようにします。
- `spawn`関数を読み込み、`bash`コマンドでシェルスクリプトを実行します。
- サーバーが起動したかどうかを判定するために、`child.stdout.on('data', (data) => {`で標準出力を監視します。(後に書くプログラムでサーバーが起動したか確認するために、コマンドを投げているので、そのコマンドの出力を監視しています)
- サーバーが起動したら、Discordに通知します。
- ログを取得するコマンドをDiscordに用意しているので、そのための`latestLog1000`変数を用意しています。
- サーバーが終了した場合は、`child.on('close', (code) => {`で終了コードを取得し、Discordからの操作で再起動するときは、`status`が`rebooting`になっているので、再度`serverStart`関数を呼び出します。

```javascript:index.js

const cron = require('node-cron');
// 10秒ごとにログをチェックして、サーバーの起動を確認する
cron.schedule('*/10 * * * * *', () => {
    if (status === "booting") {
        let currentTime = new Date().getTime();
        if (currentTime - logTimeDelay > 10000) {// 10秒以上ログが更新されていない場合にサーバーの起動が完了したか確認する
            child.stdin.write('say server boot checker\r\n');
        }
    }
});
```

このコードでサーバーが起動したかどうかを確認しています。
(もっといい方法があれば教えてください。::>_<::)

## 起動以外にもゴリ押しで搭載できる機能

### チャットをDiscordと相互に連携させることもできます。

```javascript:index.js
client.on('message', async message => {
    if (message.author.bot) return;
    if (message.channel.id === 'チャンネルID' && status === "online") {
        let author = message.author;
        let content = message.content;
        child.stdin.write(`tellraw @a {"text":"<${message.member.displayName}> ${content}","color":"${message.member.displayHexColor}"}\r\n`);
    }
});
```

`client.on('message', async message => {`でDiscordのメッセージを監視し、特定のチャンネルでのメッセージをマイクラサーバーに送信します。

### Lunaチャットをmodサーバーに導入できます。

Google Transliterate APIを使用して、ひらがなを漢字混じりの日本語に変換することができます。

shikatanさんのローマ字からひらがな変換するプログラムを使用して、Discordのメッセージをひらがなに変換します。
https://shikatan0.github.io
https://zenn.dev/shikatan/articles/8e0944dbbb432d

```javascript:index.js

function shareMessage(message) {
    // messageがゲーム内のユーザーのチャットであるか検証する。
    // 1.16.5の場合
    // [08:03:12] [Server thread/INFO] [minecraft/DedicatedServer]: <JUNmaster108> こんにちは
    // 1.20.1の場合
    // [08:03:12] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: <JUNmaster108> こんにちは
    // チャットメッセージの形式を検証する
    const chatMessage = /\[\d{2}:\d{2}:\d{2}\] \[Server thread\/INFO\] \[net.minecraft.server.MinecraftServer\/\]: <\w+> .+/;
    if (chatMessage.test(message)) {
        let username = message.match(/(?<=<)\w+(?=>)/)[0];
        let content = message.match(/(?<=> ).+/)[0];
        // ルナチャット
        let URI = "http://www.google.com/transliterate?";
        if (content.length > 15) {// 15文字以上のメッセージは変換する
            let kana = convertToHiragana(content);
            if (content.length * 7 > kana.length * 10 && kana.length < 50) {
                let langpair = "ja-Hira|ja";
                let url = URI + "text=" + encodeURIComponent(kana) + "&langpair=" + langpair;
                fetch(url)
                    .then(response => response.json())
                    .then(data => {
                        let result = "";
                        data.forEach(element => {
                            result += element[1][0];
                        });
                        console.log(result);
                        child.stdin.write(`tellraw @a {"text":"<${username}> ${result}","color":"#c0c0c0"}\r\n`);
                        sendMessageToChat(username, content, result);
                    });
            }
            else {
                sendMessageToChat(username, content);
            }
        }
        else {
            sendMessageToChat(username, content);
        }
        function sendMessageToChat(username, content, kana = null) {
            let userdata = JSON.parse(fs.readFileSync('/home/jun/IWBot/userdata.json', 'utf8'));
            let id = userdata.filter((user) => user[0] === username);
            if (id.length === 0) return;
            let user = client.users.cache.get(id[0][1]);
            // メスガキ化
            if (id[0][2] != undefined && id[0][2]) {
                content += "ざぁこ♡";// 特定のユーザーが送ったメッセージにいたずらをする
            }
            try {
                console.log(`User: ${user.username}`);
                let embed = new EmbedBuilder()
                    .setTitle(username)
                    .setDescription(content)
                    .setThumbnail(user.displayAvatarURL())
                    .setColor(baseColor)
                    .setTimestamp();
                if (kana !== null) {
                    embed.addFields(
                        { name: 'Kana', value: kana }
                    );
                }
                chat.send({ embeds: [embed] });
            }
            catch (e) {
                console.log(e);
            }
        }
    }
}
```

`shareMessage`関数は、logを解析して、チャットメッセージをDiscordに送信します。
`convertToHiragana`関数は、ローマ字をひらがなに変換する関数です。先ほどのshikatanさんのプログラムを使用しています。

### プレイヤーの入退室をDiscordに通知することもできます。

変数`onlinePlayers`にプレイヤーの名前と入室時間を保存し、BOTのステータスにプレイヤーの数を表示します。

```javascript:index.js
function joinLeave(message) {
    // 1.16.5の場合
    // [23:55:08] [Server thread/INFO] [minecraft/DedicatedServer]: JUNmaster108 left the game
    // [23:55:08] [Server thread/INFO] [minecraft/DedicatedServer]: JUNmaster108 joined the game
    // 1.20.1の場合
    // [23:55:08] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: maguro1712 joined the game
    // MinecraftServer
    const joinLeave = /\[\d{2}:\d{2}:\d{2}\] \[Server thread\/INFO\] \[net.minecraft.server.MinecraftServer\/\]: \w+ (left|joined) the game/;
    if (joinLeave.test(message)) {
        let username = message.match(/\w+(?= (left|joined) the game)/)[0];
        let entryLeavingWhich = message.match(/(?<=\[Server thread\/INFO\] \[net.minecraft.server.MinecraftServer\/\]: \w+ )(left|joined)/)[0];
        // サーバーが起動中の場合キックしてreturn
        if (status === "booting") {
            child.stdin.write(`kick ${username} サーバーが起動中だよん\r\n`);
            return;
        }
        if (entryLeavingWhich === 'joined') {
            onlinePlayers.set(username, new Date().getTime());
        }
        else {
            onlinePlayers.delete(username);
        }
        // BOTのステータスにプレイヤーの数を表示する
        client.user.setActivity(`${onlinePlayers.size} players`, { type: ActivityType.Streaming });
        // BOTの自己紹介にプレイヤーの名前を表示する
        let description = '';
        onlinePlayers.forEach((value, key) => {
            description += key + '\n';
        });
        client.user.setPresence({ activities: [{ name: description, type: ActivityType.Playing }] });
        let embed = new EmbedBuilder()
            .setTitle(username)
            .setDescription(entryLeavingWhich === 'joined' ? '参加したよん' + ':green_circle:' : '退出したよん' + ':red_circle:')
            .setColor(baseColor)
            .setTimestamp();
        joinLeaveC.send({ embeds: [embed] });
    }
}
```

`joinLeave`関数は、同じようにサーバーのログを解析して、プレイヤーの入退室をDiscordに通知します。

## まとめ

Discord.jsを使用して、マイクラサーバーをDiscordから遠隔操作する方法を紹介しました。
他にも、DiscordのBotを使って様々な機能を追加することができます。
ぜひ、自分だけのBotを作成してみてください。
今回紹介したコードは、あまり良いコードではありませんが、とりあえず動くものを作ってみたいという方には参考になるかもしれません。
あとで、GitHubに公開しておこうと思いますので、興味がある方はそちらを参照してください。

https://github.com/JUN-SUZU/IWBot

```shell
git clone https://github.com/JUN-SUZU/IWBot.git
```

## 追記2024/11/23

最近kaftmusって名前に変えて作り直してます。

https://github.com/JUN-SUZU/kaftmus