discord botのコマンド実行を制限するためのcheck

# 初めに


- Python 3.10.6
- discord.py 2.1.0

# やりたいこと
minecraft serverではサーバーの停止には権限が必要です．ですがdiscord bot上で操作する際にはユーザーが行うのではなく，サーバーに直接コマンドを入力して操作します．そのため，discord上にいる人が誰でもサーバーの停止ができる状態になってしまいます．これを解決するために，操作を行う人が**必要な権限などを持っているかどうかを判定したい**です．コマンドを実行するときの処理内で条件分岐をすればできますが，discord.pyでは権限などの判定ができるデコレータが存在するのでそちらを使います．こっちを使うことで再利用性が高まる，と思います．

基本のコードは以下のものを利用します．

<details><summary>基本</summary>

```python: python
from discord import Client, Intents, Interaction, app_commands
from discord.app_commands import CommandTree
import os


class MyClient(Client):
    def __init__(self, *, intents: Intents):
        super().__init__(intents=intents)
        self.tree = CommandTree(self)

    async def setup_hook(self) -> None:
        await super().setup_hook()
        synced_commands = await self.tree.sync()
        print(synced_commands)

    async def on_ready(self):
        print(f"Logged in as {client.user} (ID: {client.user.id})")
        print("------")


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

```

</details>

というわけでそのデコレータの使い方を見ていきましょう．

# check
[`discord.app_commands.check`デコレータ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.check)を利用すると，下に記述するアプリケーションコマンドを実行できるかチェックする関数を登録できます．

## 内容

サンプルがあるのでそちらを参考に作っていきます．サーバー管理者かどうか，まあ要するに自分かどうかを判定させます．引数に`Intaraction`を持つ判定をする関数を作り，その関数をデコレータに渡すようです．

```python: python
from discord import Client, Intents, Interaction, app_commands
from discord.app_commands import CommandTree
import os

# 省略

client = MyClient(intents=intents)


def is_admin(interaction: Interaction):
    return interaction.user.id == 851408507194572821


@client.tree.command()
@app_commands.check(is_admin)
async def check_is_admin(interaction: Interaction):
    await interaction.response.send_message("you are admin", ephemeral=True)


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

これを実行してみると，

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/8dba8189-6368-7841-5965-8e9fd7e3cfbf.png)

こんな感じで無事に判定できました．これがもし，以下のようなitohal_managerかどうか判定する場合，エラーが発生します．[^error]

```python: python
def is_bot(interaction: Interaction):
    return interaction.user.id == 1030708397714198538


@client.tree.command()
@app_commands.check(is_bot)
async def check_is_bot(interaction: Interaction):
    await interaction.response.send_message("you are bot", ephemeral=True)
```

```
discord.app_commands.errors.CheckFailure: The check functions for command 'check_is_bot' failed.
```

[^error]: checkが失敗した場合に何か処理をするときは，[Clientの`on_error`メソッド](https://discordpy.readthedocs.io/ja/latest/api.html#discord.Client.on_error)で[`CheckFailure`という例外](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.CheckFailure)を判定し，そこで処理を書くと思います．

# checks

checkデコレータでは判定したいことを関数で書いて登録しました．しかし，一部の処理はdiscord.pyでデコレータとして既に利用できるものがあります．

## has_role / has_any_role

名前の通り，[has_role](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.checks.has_role) もしくは [has_any_role](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.checks.has_any_role)でコマンドを実行した人が，判定したい対象のロールを持っているかどうかを確認できます．両者の違いは判定するロールが一つか複数かです．また，判定するロールはIDでもそのルールの名前でもどちらでも大丈夫です．

自分にkeeperロールとyesロールを付与しておきました．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/97af3dfb-d511-418c-5a6a-f17fbf874f7d.png)

これで以下のコマンドが実行できるか試します．ここで，`has_any_role`デコレータに渡しているロールはyesロールとnoロールのIDです．

```python: python
@client.tree.command()
@app_commands.checks.has_role("keeper")
async def is_keeper(interaction: Interaction):
    await interaction.response.send_message("no goal!!!!", ephemeral=True)


@client.tree.command()
@app_commands.checks.has_any_role(1054345096620945408, 1054345230389882980)
async def has_yes_or_no(interaction: Interaction):
    await interaction.response.send_message(
        "you have yes or no role. right? \n> left \t right", ephemeral=True
    )
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/f9e372a3-3c00-b9ef-6923-3dd13b3904ad.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/8af50c87-4b0c-a3f2-4b88-c9c13aad6450.png)

こんな感じで両方とも実行してくれました．


## has_permissions

[`has_permissions`デコレータ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.checks.has_permissions)は，コマンドを実行する人が指定した権限を，指定した真偽値であるか判定します．つまり，権限を持っているときに実行してほしければTrueを，持っていないときに実行してほしければFalseを指定します．また，判定したい権限は[`discord.Permissions`にあるプロパティ](https://discordpy.readthedocs.io/ja/latest/api.html#discord.Permissions)の名前を指定します．`discord.Permissions`をimportする必要はありませんでした．

以下ではコマンドを実行した人が，チャンネル削除などをできる権限を持っていればコマンドを実行するようにしています．

```python: python
@client.tree.command()
@app_commands.checks.has_permissions(manage_channels=True)
async def can_manage_channel(interaction: Interaction):
    await interaction.response.send_message("channel < 'do not remove......'")
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/fe09ae15-44e9-bc1f-91dc-7ad002ee8089.png)

権限を持っていれば実行できますね．

## bot_has_permissions

[`bot_has_permissions`デコレータ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.checks.bot_has_permissions)は，コマンドを実行した人ではなく，bot自体にその権限があるかどうかを確認します．

以下はbotがメッセージを送信できればコマンドを実行できます．

```python: python
@app_commands.checks.bot_has_permissions(send_messages=True)
async def can_send_massage(interaction: Interaction):
    await interaction.response.send_message("What were you watching?")
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/0bed505b-ee6d-210a-4168-16d5b0f08a95.png)

そして，以下のコマンドはbotがチャンネル削除などの権限を持っていなければ実行できます．

```python: python
@client.tree.command()
@app_commands.checks.bot_has_permissions(manage_channels=False)
async def can_bot_manage_channel(interaction: Interaction):
    await interaction.response.send_message("channel < ☜(ﾟヮﾟ☜) (☞ﾟヮﾟ)☞")
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/ac8b4c1b-3a7d-a15e-5463-142cf02dd55c.png)



## cooldown

[cooldown](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.checks.cooldown)では，コマンドを連続実行する際の間隔を設定できます．何秒間(per)の間に何回(rate)実行できるかを引数に`cooldown(rate, per)`などと指定します．また，`key`という引数にクールダウンを発生させるインタラクションの対象を設定できます．つまり，ユーザーごとにクールダウンを発生させるかやチャンネルごとにクールダウンを発生させるということができます．具体的には，ユーザー単位でクールダウンを発生させる場合，あるユーザーがクールダウン中でも他のユーザーはコマンドを実行できる，といったことです．

![cooldown.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/97b2e4d3-9439-8474-8553-532b52192089.gif)


### dynamic_cooldown

[dynamic_cooldown](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.checks.dynamic_cooldown)では引数に，[Cooldownクラス](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#cooldown)を返すような関数を指定します．返すCooldownクラスには，cooldownデコレータと同様に`rate, per(, key)`を引数に渡します．

サンプルがあるのでそちらを見ると，
```python
def cooldown_for_everyone_but_me(interaction: discord.Interaction) -> Optional[app_commands.Cooldown]:
    if interaction.user.id == 80088516616269824:
        return None
    return app_commands.Cooldown(1, 10.0)

@tree.command()
@app_commands.checks.dynamic_cooldown(cooldown_for_everyone_but_me)
async def test(interaction: discord.Interaction):
    await interaction.response.send_message('Hello')

@test.error
async def on_test_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
    if isinstance(error, app_commands.CommandOnCooldown):
        await interaction.response.send_message(str(error), ephemeral=True)
```

とあります．`cooldown_for_everyone_but_me`において，自身であればcooldownは発生させずに連続でコマンドを実行できるようにしているようです．


以上となります．discord botのコマンド実行を制限するためのcheckを紹介しました．ちなみにこれ[自身でデコレータを作成](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.app_commands.check)して簡素化できるようです．

お疲れ様でした．