py-cordのUI KITを使ってボタンやドロップダウンメニューを実装する方法

# はじめに
この記事ではPythonのDiscordでBOTを作成する為のライブラリの`py-cord`を利用したUIの作成について説明させていただいています。

## 前提知識
主に`py-cord`でコマンドを実装したことがある方であればおおよそわかる内容かと思います。
もし、一度も実装した事がない場合は[こちらの記事](https://qiita.com/nikawamikan/items/1c253ce9b05d7edf1dd0)などを参考にして基本的なコマンドを実装してみてください。
python自体のクラス継承なども登場しますが、ある程度解説するので必須ではありません。

## 環境
- python 3.10.6
- py-cord 2.1.3

# もっとも簡単な方法

`py-cord`では簡潔な記述でボタンを実装するデコレータが用意されているため、最も簡単な実装では以下のようにボタンを実装することができます。

この情報はほとんど[Pycord公式のガイド](https://guide.pycord.dev/)となりますので、そちらで参考してもいいと思います。

## ボタンの場合

```py
from discord.ext import commands
import discord

intent = discord.Intents.default()
intent.messages = True

bot = commands.Bot(
    debug_guilds=[000000000],  # Guild id
    intent=intent
)


# Viewクラスを継承してButtonを持ったViewを作成します。
class TestView(discord.ui.View):
    # デコレータを使ってButtonオブジェクトをViewのitemsに格納する
    @discord.ui.button(label="ボタン１", style=discord.ButtonStyle.green)
    async def first_button(self, button: discord.ui.Button, interaction: discord.Interaction):
        button.disabled = True  # ボタンの無効化
        await interaction.response.edit_message(content="ボタンが押されました", view=self)


# Viewを呼び出すためのコマンドを定義
@bot.slash_command(name="view")
async def view_test(ctx: discord.ApplicationContext):
    view = TestView()
    await ctx.interaction.response.send_message(content="テストのボタンです", view=view)


bot.run("TOKEN")
```
簡単に解説するとデコレータの`label`がボタンの表示名で、`first_button`関数の引数になっている`button`は`self`(Viewのこと)に存在するbuttonオブジェクトになります。
つまり、この動作では`ボタン１`のラベルのボタンが押された時そのボタンを無効化(グレーアウト)し、ボタンがおされた事を通知する動作となります。

この記述方法であれば
1. Viewを継承クラスを用意する
1. デコレータを使ってボタンの動作を定義する
1. コマンドからViewを呼び出す


の3ステップで記述することが可能でとても扱いやすいです。

## ドロップダウンメニューの場合

```py
# Viewクラスを継承してButtonを持ったViewを
class TestView(discord.ui.View):
    # デコレータを使ってSelectオブジェクトをViewのitemsに格納する
    @discord.ui.select(placeholder="選択", options=[
        discord.SelectOption(label="選択肢1", value="1"),
        discord.SelectOption(label="選択肢2", value="2"),
        discord.SelectOption(label="選択肢3", value="3"),
    ])
    # ユーザーがドロップダウンメニューから選択したときの動作
    async def select_menu(self, select: discord.ui.Select, interaction: discord.Interaction):
        await interaction.response.edit_message(content=f"あなたが選択したのは {select.values[0]} です", view=self)


@bot.slash_command(name="view")
async def view_test(ctx: discord.ApplicationContext):
    view = TestView()
    await ctx.interaction.response.send_message(content="テストのドロップダウンメニューです", view=view)
```

こちらも簡単に解説するとデコレータの`placeholder`がドロップダウンメニューを選択していない時に表示されるラベルで、メニューを押した時に`options`の配列から各labelを表示します。
`select_menu`関数の引数になっている`select`は`self`(Viewのこと)に存在する`Select`オブジェクトになります。
この動作では`select`のラベルのメニューが選択された時、そのvalueの値を取得して選択したものを通知しています。

こちらも同様に以下の3ステップで実装可能です。
1. Viewを継承クラスを用意する
1. デコレータを使ってドロップダウンメニューの動作を定義する
1. コマンドからViewを呼び出す

# 必ず任意のボタンを持たせた状態での実装
上記のオブジェクトはどちらのViewを継承しており、自作のViewクラスを更に継承することで任意のボタンを必ず持たせたViewを作成することが可能になります。
例えば、かならず`終了する`ボタンがあるViewを送信したいときなどに便利です。

```py
# 必ず表示させたいボタンを持ったViewを定義します
class BaseView(discord.ui.View):
    # rowを4に設定し一番下に表示されるようにする
    @discord.ui.button(label="終了する", style=discord.ButtonStyle.red, row=4)
    # もし、コールバック関数の中で使用しない引数が存在してしまう場合は _ にすると親切です
    async def exit_button(self, _: discord.ui.Button, interaction: discord.Interaction):
        await interaction.response.edit_message(content="お疲れ様でした", view=None)
        await interaction.delete_original_message(delay=10)


# BaseViewを継承したクラスを定義します
class ButtonTest(BaseView):
    @discord.ui.button(label="ボタン１", style=discord.ButtonStyle.green)
    async def first_button(self, button: discord.ui.Button, interaction: discord.Interaction):
        button.disabled = True
        await interaction.response.edit_message(content="ボタンが押されました", view=self)


class SelectTest(BaseView):
    @discord.ui.select(placeholder="選択", options=[
        discord.SelectOption(label="選択肢1", value="1"),
        discord.SelectOption(label="選択肢2", value="2"),
        discord.SelectOption(label="選択肢3", value="3"),
    ])
    async def select_callback(self, select: discord.ui.Select, interaction: discord.Interaction):
        await interaction.response.edit_message(content=f"あなたが選択したのは {select.values[0]} です", view=self)


@bot.slash_command(name="testbutton")
async def view_test(ctx: discord.ApplicationContext):
    view = ButtonTest()
    await ctx.interaction.response.send_message(content="テストのボタンです", view=view)


@bot.slash_command(name="testselect")
async def view_test(ctx: discord.ApplicationContext):
    view = SelectTest()
    await ctx.interaction.response.send_message(content="テストのドロップダウンメニューです", view=view)
```

これによりどちらのコマンドを呼び出した場合にも`終了する`ボタンが表示され動作します。

> `row`で指定できる数は0~4で各ボタンなどの順番を決めるのに役立ちます。


# 動的な実装を行う場合
先程は定数的なボタンやドロップダウンメニューを実装する方法を解説しました。
例えば「ユーザーがコマンドにて値を入力してそれを何かしらの方法で値を保持し、それをドロップダウンメニューで選択できるようにする。」といった動作をさせる場合はデコレータを利用した方法だと行うことができません。
このような動作を実装する場合はButtonクラスやSelectクラスを継承する必要が出てきます。

ここでは`Select`クラスを継承して動的なメニュー表示を実装する方法を解説します。

```py
# ユーザー入力を保存するためのlistを定義
options: list[discord.SelectOption] = []


# Selectクラスを継承し、処理の実装などを行います。
class UserInputSelect(discord.ui.Select):
    def __init__(self, options: list[discord.SelectOption] = ...) -> None:
        # placeholderは定数で良いのでここでは選択としています。
        super().__init__(placeholder="選択", options=options)

    # callbackメソッドをオーバーライドして処理を実装します。
    async def callback(self, interaction: discord.Interaction):
        value = self.values[0]
        await interaction.response.edit_message(content=f"あなたが選択したのは{value}です")


class TestView(discord.ui.View):
    def __init__(self, options: list[discord.SelectOption]):
        # 5分後にボタンなどを無効にする様に設定
        super().__init__(timeout=300, disable_on_timeout=True)
        # optionsを使ってオブジェクトを作成しitemsに格納する
        self.add_item(UserInputSelect(options=options))


# ドロップダウンメニューにデータを追加するためのコマンドを実装
@bot.slash_command(name="add_option")
async def add_option(ctx: discord.ApplicationContext, label: str, value: str):
    # 受け取った値からoptionsに値を追加していきます。
    options.append(discord.SelectOption(label=label, value=value))
    await ctx.interaction.response.send_message(content=f"ラベル:{label}, 値:{value} で登録しました")


@bot.slash_command(name="select_menu")
async def select_menu(ctx: discord.ApplicationContext):
    if len(options) > 0:
        view = TestView(options=options)
        await ctx.interaction.response.send_message(content="ドロップダウンメニュー", view=view)
    else:
        await ctx.interaction.response.send_message(content="まだ値が入力されていません。")
```

`Select`クラスを継承することで任意の処理をオーバーライドした`callback`に記述することが可能です。
`options`を受け取りオブジェクトを作成し、`add_item`メソッドにて`items`に格納しています。
デコレータを使った場合内部的に同じような事が行われているため、とても簡潔に記述することが可能となっています。

> `disable_on_timeout`はpy-cord 2.1からの機能みたいなので実装する場合は注意してください。
このオプションは`timeout`で設定された秒数が経過後にボタンなどをすべて無効にする処理を行うかの設定です。
不要な場合は`False`にするかこの引数自体を消してしまっても問題ありません。

# 参考にさせて頂いたもの
https://docs.pycord.dev/en/master/api.html#discord.ext.commands.DefaultHelpCommand

https://guide.pycord.dev/

https://github.com/Pycord-Development/pycord/tree/v2.1.3/examples/views