discord botのcooldownとinteraction_check

# 前提
この記事はminecraft serverをdiscord botから操作しようというアドベントカレンダーの9日目です．
内容としては，
- コマンドを打つことができるチャンネルの制限の設定
- コマンド連打してくる悪い人への対策のためにcommandにcooldownを設定すること

といった細々としたことを説明します．
また，
- WSL
    - Ubuntu 22.04.1
- Python 3.10.6
- discord.py

という環境です．
そして，discordにおけるサーバーとminecraftのサーバーが若干ややこしいかもしれないので，discordにおけるサーバーはギルドと呼びます


# コマンドへの反応を制限
discord botにおいて，コマンドは基本誰でもどこでも利用できます．どこでもというのは，どのギルドでも，どのチャンネルでも，誰でも利用できるということです．そのため，あちこちに反応されると大事なログが流れるかもしれません．
その対策として，反応するチャンネルや反応するユーザーを制限しましょう．ギルドの制限に関しては，そもそもそのアプリを必要としているからbotを入れているわけですから，まあ今回は考えないことにしましょう．

さて，反応するチャンネルや反応するユーザーを確認するため`interaction_check()`というものを利用します．

## interaction_check()

`interaction_check()`は[ui.View()](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.View.interaction_check)や[CommandTree](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.CommandTree.interaction_check)，[Group](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.Group.interaction_check)などで利用できるメソッドです．
これはinteraction，つまりコマンドを打ち込んだときに実行されるものです．引数にinteractionを取ります．返り値はコマンドを実行して良いかどうかのboolです．
また，返り値としてfalseとなった場合，`on_error()`メソッドで処理することもできます．実装しなくても大丈夫といえば大丈夫です．ログにエラー出力されますけどね．


これを利用して，[前回記事](https://qiita.com/halglobe0108/items/095ba4e080363554aa25)に書いたコードを修正していきます．
コードは以下の通りです．

<details><summary>discord bot x minecraft serverのコード</summary>

```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")
```

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


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:
        self.tree.add_command(MinecraftCmd(self.server))
        await self.tree.sync()

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

</details>

## チャンネルの制限

まずは現在の状態を確認しておきましょう．チャンネルの制限をしていない場合は以下の通りです．

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/89af7bc5-0ced-baca-d732-72de72adb396.png">

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/653bd2a3-a00b-f953-3c03-8f4ee411c940.png">

1. generalでminecraft serverを起動し
1. minecraft_channelで停止ができている

という状態です．generalに大切なログがあった場合，探すのにこいつは邪魔です．そのため，minecraft_channelのときだけ反応するようにしましょう．

`Group`にinteraction_check()を追加しましょう．この`Group`はminecraft serverの起動と停止をするコマンドグループです．よって，ここに反応するチャンネルを制限しておきます．そのためにはチャンネルIDを取得する必要があります．

以下の歯車から設定画面を開いて，
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/4f1db0fb-3536-9bab-225f-9cb3462b08c8.png)

アプリの設定 > 詳細設定から開発者モードをONにします．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/0b9f6192-5d5e-8c1a-7879-418b31047287.png)

これでチャンネルに対して右クリックをすればチャンネルIDがコピーできるようになります．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/d6fd008b-920e-fe2a-72d4-add978a2d8fa.png)

ではコードを改造していきましょう．

```diff_python: MinecraftCommand.py
- from discord import app_commands, Interaction
+ from discord import app_commands, Interaction, Client
+ from discord.app_commands import AppCommandError
from MCServer import MCServer


class MinecraftCmd(app_commands.Group):
-   def __init__(self, server):
+   def __init__(self, server, client: Client):
        super().__init__(name="minecraft")
        self.server: MCServer = server
+       self.allowed_channel_id = 1050795597852053565
+       self.client = client

    @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")

+   async def interaction_check(self, interaction: Interaction, /) -> bool:
+       return interaction.channel_id == self.allowed_channel_id
+
+   async def on_error(self, interaction: Interaction, error: AppCommandError, /) -> None:
+       allowed_channel = self.client.get_channel(1050795597852053565)
+       await allowed_channel.send(f"{interaction.user.mention}. plese this channel to run command")
+       print(error)

```

これを実行してみます．
generalの方では
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/80f0c343-51ec-37de-7f84-7970d80e31a6.png)

となり，minecraft_channelの方で

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/d52866a1-34bf-b6fa-e42f-76725719525d.png)

と送られました．そしてminecraft_channelの方でコマンドを入力すると

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/f4b5611e-261b-0658-2fd1-ebbf9726db57.png)

無事に実行することができました．
人の制限についても，`interaction`の`user`を比較すればできると思います．

# cooldown

コマンドは何回でも入力可能です．そのため，

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/ba3a4714-3bc9-e49a-9e37-6227bf4b4828.gif">

画質がめっちゃ荒いですが，なんとなく想定している動作ではないことはわかるでしょう．この原因は短時間に何度もコマンドが動作してしまうからです．そのため，一度コマンドを打ったら，再度実行できるまで猶予をもらいましょう．
そのために[cooldown](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.checks.cooldown)を設定します．またリファレンスにサンプルがあるのでそれを参考にします．

cooldownは`appcommands.checks`にあるデコレータです．引数は，クールダウン設定までの使用回数，クールダウンの長さ，制限の範囲です．

cooldownを適用させたコードが以下の通りです．

```diff_python: MinecraftCommand.py
from discord import app_commands, Interaction, Client
from discord.app_commands import AppCommandError
from MCServer import MCServer


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

    @app_commands.command()
+   @app_commands.checks.cooldown(1, 30)
    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()
+   @app_commands.checks.cooldown(1, 30)
    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")

    async def interaction_check(self, interaction: Interaction, /) -> bool:
        return interaction.channel_id == self.allowed_channel_id

+   async def on_error(self, interaction: Interaction, error: AppCommandError, /) -> None:
+       if isinstance(error, app_commands.CommandOnCooldown):
+           await interaction.response.send_message(str(error), ephemeral=True)
+       elif isinstance(error, app_commands.CommandOnCooldown):
+           allowed_channel = self.client.get_channel(1050795597852053565)
+           await allowed_channel.send(f"{interaction.user.mention}. plese this channel to run command")
+           print(error)
```

実行すると以下のようになりました．

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/7801f5d0-db2e-e7fb-5e0b-a94c0be24119.gif">

多少エラー出てますが，おおむねやりたいことができています．まあコマンドのたびにクールダウン中だよって表示されてしまいますが．


以上となります．
- コマンドを打つことができるチャンネルの制限の設定
- コマンド連打対策のためにcommandにcooldownを設定すること

を説明しました．
お疲れ様でした．