discord.py 2.0.0aで、view.from_messageでViewを取得した時にcallbackが実装されない問題を解決する

最近、趣味で`discord.py`を用いてちょっとしたdiscord botを作っています。
UIに少し凝って作り込んだ時にハマったので、記事にしたいと思います。

また、現在`discord.py`はalpha版であり、安定していないことから、これが変わる可能性もあります。
また、このことから現在はサードパーティライブラリを使わないで実装しております(サードパーティを使ってみたら動かなかったため)。

今回用いた環境を書いておきます。

```
Python 3.9.2
discord.py-2.0.0a4070
```

# TL;DR

- `View.from_message()`ではViewのコピーしか行わなく、ButtonのCallbackなどはコピーしてくれない。
- 自分のViewクラスを作ったとき、`from_message()`もオーバーライドしてViewをコンポーネントから再構築する必要がある

# 起こった事象

今回作りたかったボットの仕様の一部として、

1. 図1ようなボタンがあって、クリックされるとボタンの色が変わり、図2のような"キャンセル"ボタンが出現します。
1. キャンセルボタンがクリックされると、図1の状態に戻り、1.の動作が再び行えるようにする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/7b7c7484-9866-f596-b40a-589e3d0f268a.png)
図1: ボタンがクリックされる前の状態

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/bf238ec0-ebd7-fb8d-bf80-dd6b59a50fee.png)
図2: ボタンがクリックされた後の状態

これを以下のように実装しました。

```py
CHANNEL_ID = (チャンネルID)
TOKEN = (トークン)

intent = discord.Intents.all()
bot = commands.Bot(command_prefix=commands.when_mentioned_or('$'), intents=intent)

@bot.event
async def on_ready():
    print('login successfully')

    ############### begin of UI ###############
# メインとなるボタン
class MainButton(Button):
    def __init__(self):
        super().__init__(label='Button',style=discord.ButtonStyle.primary)

    async def callback(self, interaction: Interaction):
        assert self.view is not None
        view: View = self.view
        print(view.is_finished())
        self.style = discord.ButtonStyle.danger
        await interaction.response.edit_message(view=view)
        await interaction.followup.send(content=f'clicked',view=ReplyView())
        print(f'{interaction.user.name} clicked')

# ボタンを並べるView            
class RowView(View):
    def __init__(self):
        super().__init__(timeout=None)
        self.add_item(MainButton())

#MainButtonが押された時に返信されるView
class ReplyView(View):
    def __init__(self):
        super().__init__(timeout=None)
        self.add_item(CancelButton())

# キャンセルボタン
class CancelButton(Button):
    def __init__(self):
        super().__init__(label=f'Cancel', style=discord.ButtonStyle.secondary, emoji='❌')
    async def callback(self, interaction: Interaction):
        assert self.view is not None
        this_view: View = self.view
        message = await bot.get_channel(CHANNEL_ID).fetch_message(message_id_list[0])
        view: View = View.from_message(message)
        view.children[0].style = discord.ButtonStyle.primary        
        await message.edit(view=view)
        print(f'{interaction.user.name}  canceled')     
        await interaction.message.delete()
        this_view.stop()

    ############### end of UI ###############

message_id_list = list()    
@bot.command()
async def execute(ctx):
    message = await ctx.send(view=RowView())
    message_id_list.append(message.id)
    
bot.run(TOKEN)
```

これを実行し、上の手順1.,2.を行ったあと、もう一度手順1.を行うと以下のようにインタラクションに失敗してしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/463b773a-526f-1c10-1bd3-42463aff88da.png)

この原因が分からず1日ほど悩み、Bot作っている知り合いに聞いてみたところ、原因が判明しました。

# 原因

どうやらこの`View.from_message()`関数は、View(コンポーネントなどの外見)だけをコピーしているだけのようで、中身のボタンのインスタンスまではコピーしていないようです。なので、ボタンのインスタンスに含まれるCallback関数が呼ばれないとのこと。
 
# 解決法

それなら、`View.from_message()`関数を自分が継承して作った`RowView`でオーバーライドしてしまえば良くね?ってなり、このような感じにしました。

```py
class RowView(View):
# initは同様
#    ......

# from_message()をオーバーライド
    @classmethod
    def from_message(cls,message):
        cls = super().from_message(message)
        cls.clear_items()
        cls.add_item(MainButton())            
        return cls
```
`View.from_message()`はクラスメソッドなので、このように書けばOK。返すクラスでViewを消して、`MainButton()`を入れ直してあげます。

次に`CancelButton`クラス内のコールバックで、
```py
view: View = View.from_message(message)
```
を
```py
view: RowView = RowView.from_message(message)
```

と、自分で作ったサブクラスの`from_message()`を呼べばOK。
これで手順1.,2.を永遠にループ出来るようになりました。
また、メッセージが投稿されるわけではないので、ボタンがついているメッセージが複数あっても順番が入れ替わることもありません。

# 参考
https://discordpy.readthedocs.io/ja/latest/interactions/api.html?highlight=view#discord.ui.View.from_message