minecraft serverのop権限をdiscord botのコマンド実行権限に対応させたい

# 前提

これは，[discord botを使ってminecraft serverを操作したいアドベントカレンダー](https://qiita.com/advent-calendar/2022/itohal)の記事です．

そしてこの記事は，minecraft serverのop権限をdiscord botのコマンド実行権限に対応させることを**考える**記事です．対応のためのプログラムは実装していますが，その通りにすればいいというわけではありません．予めご了承ください．

また，環境は

- WSL, Ubuntu 22.04.1
- minecraft server 1.19.2
- Python 3.10.6

となっています．

# botから使用するコマンドの想定

まず，discord botから実行するminecraft sererへのコマンドを考えます．

使いそうなコマンドは`list`や`op`，`msg`/`tell`ぐらいでしょうか．これらのコマンドは

- `list`
    - 現在ログインしている人数と，誰がログインしているかの表示
- `op`
    - serverに対するoperater権限を与える
    - 私のpapermcの環境だと，**ops.jsonのlevelを変更してもlevel4相当の権限になりました**
        - ほかに設定があるかもしれませんが，[papermcのドキュメント](https://docs.papermc.io/)それっぽいやつがなかったので諦めました
    - 公式のjarファイルだと`ops.json`を変更しserverを再起動することで権限が更新されます
- `msg`/`tell`
    - 誰かにメッセージを送る

という感じのものです．これらのコマンドで実行するのに権限が必要なものは`op`だけなので必要性が正直ないかもしれません．ですがそもそも`stop()`についても本来であれば権限が必要なのでそこには適用できるでしょう．

# minecraftのユーザ名とdiscordの名前を一致させる．

次の話です．
discord botにからminecraft serverの権限を確認するには，serverがあるディレクトリ内の`ops.json`を確認するのがいいと思います．`ops.json`には，ユーザーID，ユーザー名とそれに対応した権限レベルなどが格納されています[^mc_ops]．しかし，`ops.json`に記述されているユーザー名はminecraftで遊ぶ時のユーザー名ですので，discord上の名前と一致するとは限りません．そのため，この対応をさせます．

ここで，`ops.json`は初期状態だと何も入っていないので，最初は手動で`op`コマンドを実行する必要があります．そのため，とりあえず`op`コマンドを実行しておいてください．

[^mc_ops]: `bypassesPlayerLimit`については[こちらの`max-players`の項目](https://minecraft.fandom.com/ja/wiki/Server.properties)を見てください．

## どうするか

具体的に何をするかを考えたのですが，この記事では，`ops.json`に記述されているような形式で，minecraftのユーザーID，minecraftのユーザー名，discordのユーザーIDをもつJSONの配列でファイルを作りましょう．
また，そのJSONファイルの更新は，`context_menu()`を使ってdiscord上で自分を右クリックしたときのアプリを実行したときにします．`context_menu()`については[少しだけ説明した記事を書いている](https://qiita.com/halglobe0108/items/db982af6b08844051606)ので見ていただけると嬉しいです．


# プログラム
## ops.jsonの内容を取得
まずは，minecraft serverのあるディレクトリの`ops.json`の中身を取得して，自分が使うJSONに直してファイルに保存する部分の説明です．ここで自分が使うJSONファイルを`ops.json`としています．区別のため，「discordの`ops.json`」などと呼びます．~~競合するのはここだけなので見逃してください~~
コードが以下の通りです．

```diff_python: discordbot.py
from MCServer import MCServer
from MinecraftCommand import MinecraftCmd
from discord import Intents, Client
from discord.app_commands import CommandTree
import json
import os


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

    async def setup_hook(self) -> None:
        self.tree.add_command(MinecraftCmd(self.server, self))
        await self.tree.sync()

    async def on_ready(self):
        print(f"login: {self.user.name} [{self.user.id}]")
        self.channel = self.get_channel(1047700283712622643)

    def get_ops(self) -> dict:
        json_file_name = "ops.json"
        if os.path.exists(f"./{json_file_name}"):
            ops = {}
            with open(json_file_name, "r") as file:
                ops = json.load(file)

            return ops

        ops = []
        ops_json = None
        with open("../mcserver/ops.json") as file:
            ops_json = json.load(file)

        for user in ops_json:
            ops.append(
                {"uuid": user["uuid"],
                 "name": user["name"],
                 "id": None}
            )

        with open("ops.json", "w") as write_file:
            print("dump")
            json.dump(ops, write_file, indent=2)

        return ops

```

内容を取得する箇所は`get_ops()`メソッドです．
そもそもすでにdiscordの`ops.json`が存在するなら，すでにminecraftの`ops.json`は読み込まれているとして早期リターンしています．理由としては，そのあとのコードでidを初期化しているからです．~~すでにidの値が存在しているときは何もしないという条件分岐の方がよかったなと今になって思いました．見逃してください~~
その後は単純にファイルの中身を読んで，自分が使う形に変形し，JSONとして出力し，discordの`ops.json`を返しているだけです．

## `context_menu()`部

続いて`context_menu()`部のコードが以下の通りです．

```diff_python: main.py
import os
import json
from discord import Intents, Client, Interaction, Member
from discord.ui import Select, View
from discordbot import MCClient
from dotenv import load_dotenv
load_dotenv()


class UnregisteredNameSelect(Select):
    def __init__(self, *, placeholder: str = None, client: Client) -> None:
        super().__init__(placeholder=placeholder)
        self.client = client

    async def callback(self, interaction: Interaction):
        self.client.ops[int(self.values[0])]["id"] = interaction.user.id
        with open("ops.json", "w") as write_file:
            print("dump")
            json.dump(self.client.ops, write_file, indent=2)
        await interaction.response.send_message("registered!", ephemeral=True)


intents = Intents.default()
client = MCClient(intents=intents)


@client.tree.context_menu(name="register name")
async def registerName(interaction: Interaction, member: Member):
    if not interaction.user == member:
        await interaction.response.send_message("sorry. please select yourself", ephemeral=True)
        return

    unregisteredName = []
    for index, user in enumerate(client.ops):
        if user["id"] is None:
            unregisteredName.append([user["name"], index])

    if not unregisteredName:
        await interaction.response.send_message("all user registered", ephemeral=True)
        return
 
    unregisteredNameSelector = UnregisteredNameSelect(client=client)
    for item in unregisteredName:
        unregisteredNameSelector.add_option(
            label=item[0], value=item[1]
        )

    select_view = View()
    select_view.add_item(unregisteredNameSelector)
    await interaction.response.send_message(
        f"hi, {member.mention}. select your name.", ephemeral=True, view=select_view
    )


client.run(os.getenv("TOKEN"))

```

classのところは置いといて，`context_menu()`の関数のところを説明します．

まず，自分以外のユーザーに対しては登録できないように早期終了しています．
また，登録していないユーザーがいない，つまり権限を持っている人すべてが登録されていれば，その旨を伝え早期終了しています．

そのあとは登録作業のために，どの人が登録されていないかをドロップダウンリストで表示して選択させます．このドロップダウンリストの大本で選択されたときに何をするかを記述するために，`UnregisteredNameSelect`classを作っています．`Select`についてはとりあえず[ドキュメント](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#select-menus)を読んでください．とりあえず，選択されたときの処理を書いているんだなという認識で大丈夫です．
idを登録したらJSONファイルに書き込んでいます．


## 動作確認

実行してみましょう．

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/07cd62a8-9c8a-d39f-3df8-24a1fc786f9d.gif" width=800></img>

誰かを登録しようとしてもできなくて，自分を選択してみるとドロップダウンリストが操作できて，そこから選択すると登録できた表示がされました．つまりは期待した動作ができていると思います．
ただ，選択した後もドロップダウンリストが操作できるため，ここはあまり良くないでしょう．再びリストから選ぶと再登録されると思います．`Select`には`disabled `というパラメータがあるので，選択されたらそこをTrueにすればよいでしょう．

次にdiscordの`ops.json`が生成されるので中身を見てみましょう．

```json: ops.json
[
  {
    "uuid": "uuid",
    "name": "itousagi",
    "id": 000000000000000000
  }
]
```

実際には`uuid`や`id`に自分のidが入ります．

よって，minecraftのユーザーとdiscordのユーザーを紐づけることができたと思います．


以上となります．minecraft serverのop権限をdiscord botのコマンド実行権限に対応させたいという方針からいろいろ考えてきました．参考になれば幸いです．
お疲れ様でした．