discord botでドロップダウンメニューを作る

# 前提

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

また，
- WSL, ubuntu 22.04.1
- discord.py 2.1.0
- Python 3.10.6

という環境で行っています．

# Select

[公式ドキュメント](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#select)によると

> Represents a UI select menu with a list of custom options. This is represented to the user as a dropdown menu.

みたいです．`as a dropdown menu`とあるようにドロップダウンメニュー(ドロップダウンリスト)を作成するものです．名称はリストの方でもメニューの方でもいいみたいなので，この記事ではドロップダウンメニューやメニューと言うことにします．

Selectには，複数の種類があります．

- [Select](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#select)
    - 任意の選択肢を作成できる
- [ChannelSelect](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#channelselect)
    - チャンネルを選択肢として持つ
- [RoleSelect](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#roleselect)
    - ロールを選択肢として持つ
- [UserSelect](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#userselect)
    - メンバーを選択肢として持つ
- [MentionableSelect](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#mentionableselect)
    - メンションが可能である，ロールとメンバーを選択肢を持つ

:::note warn 
注意
Select以外のものはdiscord.pyのバージョン`2.1`が必要になります
:::

Select以外は，特定のものに対するショートカットみたいなものだと思っています．そのため，ここではSelectについて説明します．

## 実装

SelectはUIですので，[View](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#view)に追加する必要があります．Viewの[`add_item`というメソッド](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.View.add_item)を使用して，メッセージに組み込むUIを作っていきます．
ただし，一つも選択肢がない場合エラーが発生するため，少なくとも一つは選択肢を追加する必要があります．選択肢を追加するためには，Selectの[`add_item()メソッド`](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.Select.add_option)を使います．

具体的にコードを見ていきましょう．

```python: selectorbot.py
import os
import discord
from discord import Intents, Client, Interaction
from discord.ui import Select, View
from discord.app_commands import CommandTree
from dotenv import load_dotenv


load_dotenv()


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

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

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


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


@client.tree.command()
async def somemenu(interaction: Interaction):
    select = Select(placeholder="this is placeholder")
    select.add_option(
        label="user can see this",
        value="user can not see this",
        description="this is description",
    )

    view = View()
    view.add_item(select)

    await interaction.response.send_message("menu", view=view)


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

```


実行し，メニューを広げてみると

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/2e1001df-3746-b54e-d28c-239363758893.png)

こんな感じになります．
ちなみに現段階で選択してみると

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/df40ea63-ccf1-09be-e1c4-c372d4ec581d.png)

失敗します．
選択されたときの処理を設定していないからですね．
というわけで設定しましょう．


# 選択されたときの処理

選択されたときの処理は，Selectクラスの[`callback`メソッド](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.Select.callback)をサブクラスで上書きし，そのサブクラスをViewに渡す方法があります．ただ，これは面倒(当人比)なので別の方法を利用します．
それは，[`discord.ui.select`デコレータ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#id2)を使って，ViewのサブクラスにSelectクラスの`callback`メソッドを実装する方法です．

`discord.ui.select`デコレータの下に記述された関数は
- Selectクラスとして扱われる
- 関数内の処理はSelectクラスの`callback`メソッドとして解釈される
- Viewに追加された状態になる

となります．
これにより，
- Selectを継承したサブクラスを作成し`callback`をオーバーライドする
- ViewにSelectを追加する

といった手間から解放されます．
コードを見ていきましょう．

```diff_python: selectorbot.py
import os
import discord
from discord import Intents, Client, Interaction
from discord.ui import Select, View
from discord.app_commands import CommandTree
from dotenv import load_dotenv


load_dotenv()


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

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

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


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


+ class SelectView(View):
+     @discord.ui.select(
+         cls=Select,
+         placeholder="this is placeholder"
+     )
+     async def selectMenu(self, interaction: Interaction, select: Select):
+         await interaction.response.send_message(f"You selected {select.values}")


@client.tree.command()
async def somemenu(interaction: Interaction):
-    select = Select(placeholder="this is placeholder")
-    select.add_option(
-        label="user can see this",
-        value="user can not see this",
-        description="this is description",
-    )
-    view = View()
-    view.add_item(select)
+    view = SelectView()
+    view.selectMenu.add_option(
+        label="user can see this",
+        value="user can not see this",
+        description="this is description",
+    )

    await interaction.response.send_message("menu", view=view)


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

```

実行して選択してみると

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/1f3db059-df58-c7b9-860b-9ed496b0b23b.png)

と選択したものを表示してくれました．
ちなみに選択肢は，デコレータの引数に`options`というパラメータがあり，そこに以下のように記述しても大丈夫です．

```diff_python: SelectView class
class SelectView(View):
    @discord.ui.select(
        cls=Select,
        placeholder="this is placeholder",
+       options=[
+           discord.SelectOption(label="one"),
+       ],
    )
    async def selectMenu(self, interaction: Interaction, select: Select):
        await interaction.response.send_message(f"You selected {select.values}")

```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/e74df8a0-3f83-632c-6664-03497c8fc768.png)

このように選択肢が追加されました．
ただし，デコレータを利用して選択肢を追加しているため，デコレータ内で配列変数などを走査して選択肢を作るにはmapやfilterといったものを利用する必要があると思います．ですが，おそらくグローバル変数としておく必要があります．あやふやになってしまいましたが，Viewのインスタンスを生成した箇所で選択肢を追加するのが無難かなと思います．選択肢が明らかに少なければ`options`でもいいとも思います．

# メニューの操作

さて，実はここまでの実装をしたコードだとあまり嬉しくない動作をします．どんな動作かわかりますでしょうか．
以下をご覧ください．これは3つの選択肢から1つを選択して選んでほしいときのメニューです．

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/09e2048a-6441-8cf6-569d-83e1f63317ca.gif width=800></img>

~~どういう画質してんでしょうね~~

このように，何度も選択肢を選びなおせてしまいます．これができてしまうと過去に送られたSelectに反応できてしまいます．これへの対策としてViewには[timeoutというプロパティ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.View.timeout)を設定でき，設定された秒数が経過するとインタラクションを受け付けなくなります．また，[`on_timeout`メソッド](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.View.on_timeout)により，timeoutした際の処理を記述できます．しかし，`on_timeout`メソッドではインタラクションを引数に取りませんので，選択されたことに対して返信するには別途，Selectが送られたチャンネルの情報が必要になります．Viewを作る際の引数に渡してしまえばできますが，やや面倒です．それよりも簡単な方法があります．それは，そもそも使えなくしてしまえばいい，という方法です．

## disable

Selectには[`disable`というプロパティ](https://discordpy.readthedocs.io/ja/latest/interactions/api.html#discord.ui.Select.disabled)があります．`disable`をtrueにすると，メニューで選択ができなくなります．とりあえず使えなくしてみましょう．[ここのサイト](https://guide.pycord.dev/interactions/ui-components/dropdowns)を参考にしました．

```diff_python: SelectView class
class SelectView(View):
    def __init__(self, *, timeout: float = 2):
        super().__init__(timeout=timeout)

    async def on_timeout(self) -> None:
        print("timeout")

    @discord.ui.select(
        cls=Select,
        placeholder="this is placeholder",
+       disabled=True,
    )
    async def selectMenu(self, interaction: Interaction, select: Select):
        await interaction.response.send_message(f"You selected {select.values}")
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/95c0e8b7-60ea-b496-c363-6726cb8b63c0.png)

こんな感じで使えなくなります．

これを利用して，選択された後に使えなくしましょう．ただコード自体は単純なのですが，中身がやや複雑です．
とりあえずコードを見てみましょう．

```diff_python: SelectView class
class SelectView(View):
    def __init__(self, *, timeout: float = 2):
        super().__init__(timeout=timeout)

    async def on_timeout(self) -> None:
        print("timeout")

    @discord.ui.select(
        cls=Select,
        placeholder="this is placeholder",
-       disabled=True,
    )
    async def selectMenu(self, interaction: Interaction, select: Select):
-       await interaction.response.send_message(f"You selected {select.values}")
+       select.disabled = True
+       await interaction.response.edit_message(view=self)
+       await interaction.followup.send(f"You selected {select.values}")
```

実行してみると

![selectmenu_disable.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953650/21ad9802-e05c-ef39-bcf9-65507cd5fe47.gif)

このように，選択すると使えなくなりました．

`followup`について軽く説明します．インタラクションのresponseは一度しか利用できないため，`followup`を利用してresonseに対して返信を行っています．つまり，インタラクションに対してresponseで何か処理を行い，そのresponseに対して連なる形でメッセージを送信しています．

続いて，インタラクションというものは，ユーザーがアプリに対して操作をしたメッセージ，と解釈して問題ありません．よって，`selectMenu`に対応するインタラクションはユーザーが選択肢を選んだメッセージと言えます．
従って，`await interaction.response.edit_message(view=self)`部分は，ドロップダウンメニューがあるメッセージに対して`edit_message`を行っています．つまりメッセージを編集しています．今やりたいことはメニューを使えなくすることですので，編集するものはviewだけです．viewに対してselfを渡していますが，一旦ここは飛ばして一行前を見てください．
`select.disabled = True`という部分です．これはインタラクションが発生したメニューであるSelectのインスタンスが入っています．このインスタンスは，デコレータによってSelectクラスになった`selectMenu`によるインスタンスです．しかし，`selectMenu`は`callback`です．つまり，SelectViewのメソッドでもあります．引数の`select`を`select.disabled = True`としたにもかかわらず`edit_message(view=self)`で自分自身を渡してちゃんと動作していたのは，
- 引数のselectは，SelectVeiwのインスタンスであるviewのselectMenuというSelectのインスタンスが入っている
- このselectのdisabledをTrueにするとviewのselectMenuがdisableになる．
- disableをTrueにしている処理はview内部である
- つまり上記2つからselectMenuにおいて，メニューがdisableになったSeleceViewであるself(view)を渡しているから動作する

ということになります．

:::note warn 
注意

徹夜してる頭で考えたものですので，違う可能性が大いにあります．
:::

雑に言うと，selectに入ってるやつは自分だから，viewも自分の渡せばいいって感じだと思います．

~~何をやっていたか忘れました~~

これでメニューを選択できないようにしました．ややこしいものもありましたが，とりあえずそういうもんってしておくと生きやすいです．

以上となります．discord botでドロップダウンリストを作りました．
お疲れ様でした．