discord bot x minecraft server

# はじめに

この記事では，discord.pyをつかったdiscord botからminecraft serverの起動，停止操作を行うまでの流れを説明します．
また，
- WSL
    - Ubuntu 22.04.1
- java 18
- Python 3.10.6
- [discord.py](https://discordpy.readthedocs.io/ja/latest/index.html)
    - python 3.8以降が必要
- minecraft server
    - [papermc](https://papermc.io/)

を利用しています．


# minecraft serverの準備

まずは，minecraft serverを準備します．serverは[papermc](https://papermc.io/)を利用します．
具体的な準備の流れは[記事を書いた](https://qiita.com/halglobe0108/items/a990d099c9dd20879ecf)ので読んでくださるとありがたいです．

簡単に説明すると，
1. ディレクトリを用意する．
    - サーバーを起動する際にいるディレクトリにワールドデータなどが展開されるからです．
1. サーバーを起動
    - [papermcのRunning The Serverのところ](https://docs.papermc.io/paper/getting-started#running-the-server)に起動の仕方が書いてあります
1. eula.txtの`eula=false`を`eula=true`にする．
    - 利用規約への同意です
1. 再びサーバーを起動
1. サーバーの停止

という感じです．
これでminecraft serverの準備はできました．

# serverの起動と停止

discord botはpythonを利用するため，pythonからminecraft serverを起動，停止できるようにします．起動の際にはsubprocessを利用します．[subprocessについての記事](https://qiita.com/halglobe0108/items/4fb05b14bf362b0cdc33)も書いたので読んでくださると喜びます．
serverの起動と停止はクラスにまとめます．これによりdiscord botからの起動と停止が容易になり，コードの修正がやりやすくなると思います．
コードは以下のようになりました．

<details><summary>server操作のコード</summary>

```python: MCServer.py
import os
import subprocess


class MCServer:
    def __init__(self) -> None:
        self.process: subprocess.Popen = None

    def start(self):
        if self.process is not None:
            print("maybe already started")
            return

        jar_file_name = "paper-1.19.2-305.jar"
        jar_directory = "../mcserver/"

        # コマンドを実行するときの作業ディレクトリに
        # ワールドデータなどが展開されるため
        # 作業ディレクトリを移動
        os.chdir(jar_directory)

        memory_giga = 2
        start_command = f"java -Xms{memory_giga}G -Xmx{memory_giga}G -jar {jar_file_name} --nogui"
        start_command_list = start_command.split()

        # サーバーの起動
        print("server starting...")
        self.process = subprocess.Popen(
            start_command_list,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE
        )

        # このpythonファイルのディレクトリに戻す
        os.chdir(os.path.dirname(os.path.abspath(__file__)))

        # subprocessの出力をリアルタイムに取得
        while (self.process.poll() is None):
            stdout_byte = self.process.stdout.readline().strip()
            print(stdout_byte.decode())
            if (b"Done" in stdout_byte):
                break

        # ループを抜けた理由が，subprocessが終了したとき
        if self.process.poll() is not None:
            print("startup error. kill process")
            self.process.kill()
            self.__process_communicate()
            self.process = None

        print("server started!")

    def stop(self):
        if self.process is None:
            print("maybe server is not starting")
            return

        if self.process.poll() is not None:
            print("already server is stopping")
            return

        print("server is stopping...")
        self.__process_communicate(b"stop", timeout=30)
        self.process = None

    def __process_communicate(self, input: bytes = b"", timeout=5):

        def printOutsErrs(outs: bytes, errs: bytes):
            print("output")
            print(outs.rstrip().decode())
            print("\nerror")
            print(errs.rstrip().decode())

        try:
            outs, errs = self.process.communicate(input, timeout)
            printOutsErrs(outs, errs)
        except (subprocess.TimeoutExpired, Exception) as e:
            print(e)
            self.process.kill()
            outs, errs = self.process.communicate()
            printOutsErrs(outs, errs)
```

</details>

注意点としては，
- minecraft serverを起動する際，現在の作業ディレクトリ内にワールドデータやeula.txtが生成されるため，ワールドデータなどを保存するディレクトリに作業ディレクトリを移動する．
- メモリ使用量の初期値と最大値を一致させるため変数に入れている
- コマンドにオプションを付ける場合，リストにする必要がある．
- リアルタイム出力において，起動処理の終了を`Done`と表示されたタイミングとしている
    - おそらく，マルチスレッドで出力と入力が並行して待機しているため
    - 出力がない状態を`b''`や`b' '`として試してみたが無限ループになった
- 出力は，空白文字によるインデントによって見やすくなっている可能性があるため，[末尾の空白だけ除去`rstrip()`](https://docs.python.org/ja/3/library/stdtypes.html?highlight=strip#str.rstrip)している．
- subprocessの起動に`None`であるかの確認をしているため，subprocessが終了後に`None`と更新しないとsubprocessの再起動ができない
- `Popen.communicate()`はタイムアウトする時間を設定できるが，その場合exceptionが発生する．

といったところです．

上手くいっているか試してみましょう．

```python: main.py
from MCServer import MCServer
import time

server = MCServer()
server.start()
time.sleep(5)
server.stop()
```

実行すると，
```bash
$ python3 main.py 
server starting...
Starting org.bukkit.craftbukkit.Main
System Info: Java 18 (OpenJDK 64-Bit Server VM 18.0.2-ea+9-Ubuntu-222.04) Host: Linux 5.15.74.2-microsoft-standard-WSL2 (amd64)
Loading libraries, please wait...
2022-12-09 01:59:10,605 ServerMain WARN Advanced terminal features are not available in this environment
[01:59:13 INFO]: Building unoptimized datafixer
...
[01:59:19 INFO]: Done (1.787s)! For help, type "help"
server started!
server is stopping...
output
[01:59:19 INFO]: Timings Reset
...
[01:59:29 INFO]: Closing Server

error

```

問題なく出力されていますね．`error`って出力しているので`error`が発生したのかと思いましたが勘違いでした．中身がないなら出力しないようにした方がよさそうですね．
ともかく，これでminecraft serverの起動と停止をpythonで実行できるようになりました．

# discord botの準備

続いてdiscord botを準備します．[discord botを作成する流れの記事](https://qiita.com/halglobe0108/items/dc056cc48256eeff5526)もあります．環境変数の設定を忘れずに．

簡単に説明すると，
1. bot用アカウントを登録
1. tokenを取得
1. `.env`ファイルで環境変数にtokenを設定
1. discord.pyのインストール

という感じです．

# discord botからserverの起動と停止

では最後にdiscord botからserverの起動と停止をできるようにしましょう．
コマンドから`MCServer`クラスの`start`と`stop`メソッドを呼べば大丈夫です．

<details><summary>discord bot本体，コマンドの登録</summary>

```python: discordbot.py
from MCServer import MCServer
from discord import Intents, Client
from discord.app_commands import CommandTree
from dotenv import load_dotenv
load_dotenv()


class MCClient(Client):
    def __init__(self, intents: Intents) -> None:
        super().__init__(intents=intents)
        self.tree = CommandTree(self)
        self.server = MCServer()

    async def setup_hook(self) -> None:
        await self.tree.sync()

    async def on_ready(self):
        print(f"login: {self.user.name} [{self.user.id}]")
```

```python: MinecraftCommand.py
from discord import app_commands, Interaction
from MCServer import MCServer


class MinecraftCmd(app_commands.Group):
    def __init__(self, server):
        super().__init__(name="minecraft")
        self.server: MCServer = server

    @app_commands.command()
    async def start(self, interaction: Interaction):
        await interaction.response.send_message('minecraft server is starting...')
        started = self.server.start()
        if started:
            await interaction.followup.send(f"{interaction.user.mention}! server start successfully!")
        else:
            await interaction.followup.send(f"{interaction.user.mention}, sorry. server cannot start")

    @app_commands.command()
    async def stop(self, interaction: Interaction):
        await interaction.response.send_message('minecraft server is stopping...')
        stopped = self.server.stop()
        if stopped:
            await interaction.followup.send(f"{interaction.user.mention}! server stop successfully!")
        else:
            await interaction.followup.send(f"{interaction.user.mention}, sorry. server cannot stop")
```

</details>

実行には以下のようなコードを用意しました
```python: main.py
import os
from discord import Intents
from discordbot import MCClient
from dotenv import load_dotenv
load_dotenv()

intents = Intents.default()
client = MCClient(intents=intents)
client.run(os.getenv("TOKEN"))
```

私の環境だけかもしれませんが，相変わらずコマンドが反映されるまでそこそこな時間が必要みたいです．
気長に待っていると反映されたので実行してみましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/bd9f26dd-88b0-9eb0-71c1-2fde4951bccd.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/b39ce90a-7cc7-78cc-a3b3-f3ddd3081f5c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/4128f37f-f0c1-2695-ed0c-e7a80dea95c4.png)

無事コマンドからminecraft serverを実行できました．

以上となります．discord.pyをつかったdiscord botからminecraft serverの起動，停止操作を行うまでの流れを説明しましました．
お疲れ様でした．