DiscordチャットボットをPythonでつくる (9) - メッセージにインタラクティブなボタンを設置する

今回は、チャットメッセージにインタラクティブなボタンを設置する方法を紹介します。
また、ボタンを押した際に実行可能な、様々な種類のレスポンスについて詳しく見ていきます。
今までの記事に比べて分量が多めです。
メッセージコンポーネントについて
Discordには、テキストメッセージの他に「メッセージコンポーネント」(または単に「コンポーネント」)
と呼ばれるインタラクティブな要素を追加できるフレームワークが用意されています。
(参照: Message Components)
この機能により、ボタンやセレクトメニュ等の様々なコンポーネントを
メッセージに設置することができるようになります。
discord.pyでは、これらの機能はdiscord.ui以下に実装されています。
(参照: Bot UIキット)
1. ボタンを設置する
それでは早速、discord.uiを使用してメッセージにボタンを配置しましょう。
コンポーネントをメッセージに追加するには、はじめにdiscord.ui.Viewインスタンスを作成します。
そしてadd_itemメソッドを用いてViewインスタンスにコンポーネントを追加します。
ここからは、「OK」と書かれたボタン付きのメッセージを出力する!buttonコマンドを作っていきましょう。
- 
commands/button.pyfrom discord.ext import commands from discord import ui @commands.command(name='button') async def button_command(ctx: commands.Context): """ボタン付きメッセージを表示する.""" view = ui.View() # Viewインスタンスを作成 button = ui.Button(label='OK') # Buttonインスタンスを作成 view.add_item(button) # ViewにButtonを追加 await ctx.send(view=view) # Viewを追加したメッセージを送信 - 
chatbot.py... from commands.quiz import quiz_command from commands.button import button_command BOT_COMMANDS = [omikuji_command, quiz_command, button_command] ... 
上記のように設定後チャットボットを起動し、!buttonコマンドを実行してみましょう。
すると、以下のようにボタンが表示されるはずです。
ボタンを押してみましょう。
するとまだ何も設定していないため「インタラクションに失敗しました」と表示されます。
2.ボタンを押した際の動作を定義する
それでは次に、ボタンを押した際の動作を定義しましょう。
    2.1. ui.Buttonを継承したクラスを作成
discord.pyには、ボタン押下時の動作に関していくつか設定方法が用意されていますが、
今回はui.Buttonを継承した独自のクラスを作成する方法を紹介します。
はじめに、以下のようにui.Buttonを継承したOKButtonクラスを作成します。
class OKButton(ui.Button):
    ...
    2.2. __init__メソッドを定義
次に、インスタンスの初期化を行う__init__メソッドでは、
以下のようにlabelの初期値として「OK」を代入しておきます。
class OKButton(ui.Button):
    def __init__(self, *, label='OK', **kwargs):
        super().__init__(label=label, **kwargs)
    2.3. callbackメソッドを定義
次に、ボタンが押された際に実行されるcallbackメソッドを定義します(コルーチン関数です)。
callbackの第2引数には、発生したインタラクションを意味するInteraction型の値interactionが入ります。
(参照: APIリファレンス)
from discord import ui, Interaction
...
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        ...
次に、レスポンスの種類について見ていきます。
callbackメソッドでは、発生したinteractionに対して、レスポンスを1度だけ返す必要があります。
レスポンスは2度以上返すことは出来ないことに注意してください。
またレスポンスは3秒以内に返す必要があります。
    2.3.1. interaction.response.send_message でメッセージを返信
interaction.response.send_messageコルーチン関数を使用すると、
コンポーネントが配置されているメッセージに対する返信を行います。
以下では「OKボタンが押されました」というメッセージを返信します。
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        await interaction.response.send_message("OKボタンが押されました。")
(参照: 2.5.1 send_messageを使用した場合)
    2.3.2 interaction.response.edit_message でメッセージを編集
interaction.response.edit_messageコルーチン関数を使用すると、
コンポーネントが配置されているメッセージの編集を行います。
以下では、「OKボタンが押されました」というメッセージを設定します。
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        await interaction.response.edit_message(content="OKボタンが押されました。")
(参照: 2.5.2 edit_messageを使用した場合)
    2.3.3 interaction.response.defer で長時間作業の前にレスポンスを返す
interaction.response.deferコルーチン関数は、長時間かかる作業を実施する際に使用されます。
上述の通りレスポンスは3秒以内に返す必要があるため、
長時間作業の前に一旦レスポンスを返しておくといった用途で使用されます。
また、thinking=Trueとすると、チャット上に「<ボット名>が考え中…」と表示されるようになります。
(参照: APIリファレンス)
以下は、10秒経過後に「OKボタンが押されました。」というメッセージを返信する例です。
(interaction.followupについては後ほど紹介します。)
import asyncio
...
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        await interaction.response.defer(thinking=True)
        await asyncio.sleep(10)
        await interaction.followup.send(content="OKボタンが押されました。")
        self.view.stop()
(参照: 2.5.3. deferを使用した場合)
2.4. 作成したボタンを使用するようにコマンドを調整
最後に、作成したOKButtonを使用するようにbutton_commandを変更します。
@commands.command(name='button')
async def button_command(ctx: commands.Context):
    """ボタン付きメッセージを表示する."""
    view = ui.View()
    button = OKButton()
    view.add_item(button)
    await ctx.send(view=view)
2.5. 動作確認
チャットボットを起動して!buttonコマンドを入力し、表示されるボタンを押してみましょう。
    2.5.1 send_messageを使用した場合
interaction.response.send_messageを使用して2.3.1の設定を行った場合、
ボタンを押す度にメッセージが返信されることがわかります。
しかし、しばらくしてから再度ボタンを押すと「インタラクションに失敗しました」と表示され、
返信されなくなります。
2.5.1.1 複数回のインタラクションを禁止したい場合
ボタンを押して実行されるのは1回限りとしたい場合、ボタンのcallbackメソッドでViewを停止させます。
self.viewでボタンが設置されているViewが取得でき、
self.view.stop()でインタラクションの受け付けを停止することが出来ます。
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        await interaction.response.send_message("OKボタンが押されました。")
        self.view.stop()
変更後試してみると、2回目以降は「インタラクションに失敗しました」と表示され失敗するようになります。
2.5.1.2 一定時間経過後に「インタラクションに失敗しました」とならないようにしたい場合
一定時間経過後にインタラクションが失敗するようになるのは、ui.Viewインスタンスの初期化時に、
timeoutオプションがデフォルトで180秒(3分)に設定されていることが原因です。
無期限にしたい場合はNoneに設定してください。
@commands.command(name='button')
async def button_command(ctx: commands.Context):
    """ボタン付きメッセージを表示する."""
    view = ui.View(timeout=None)
    ...
    2.5.2 edit_messageを使用した場合
interaction.response.send_messageを使用して2.3.2の設定を行った場合、
ボタンが設置してあるメッセージのテキストが「OKボタンが押されました。」に変更されます。
2.5.2.1 ボタンを消したい場合
実行後にボタンをメッセージから消去したい場合、edit_messageのviewオプションをNoneにすると実現できます。
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        await interaction.response.edit_message(content="OKボタンが押されました。", view=None)
2.5.2.2 ボタンを無効化したい場合
実行後にボタンを無効化したい場合、以下のようにself.disabled = Trueとしてボタンを無効化後、
edit_messageの引数でview=self.viewとして実行し、メッセージに反映することで実現できます。
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        self.disabled = True
        await interaction.response.edit_message(content="OKボタンが押されました。", view=self.view)
    2.5.3. deferを使用した場合
interaction.response.deferを使用して2.3.3の設定を行った場合、
以下のように10秒間「考え中…」となった後にメッセージが返信されます。
2.6. レスポンスを複数回行いたい場合
2.3節で以下のように書いた通り、1つのインタラクションに対するレスポンスは1つのみ可能です。
callbackメソッドでは、発生したinteractionに対して、レスポンスを1度だけ返す必要があります。
レスポンスは2度以上返すことは出来ないことに注意してください。
しかし、「元のメッセージのボタンを無効化しつつ、メッセージを返信する」といったような
複数の操作を行いたい場合があります。これを実現するにはどうすればよいでしょうか?
これは、interaction.followupを使用することで実現できます。
(参照: Followup Messages, APIリファレンス)
interaction.followupはインタラクション発生後15分間有効です。
それ以上経過後に実行すると、以下のようなエラーが発生し失敗します。
discord.errors.HTTPException: 401 Unauthorized (error code: 50027): Invalid Webhook Token
(参照: Followup Messages)
    2.6.1 interaction.followup.send でメッセージを後から返信する
interaction.followup.sendを使用すると、レスポンスを返答後でもメッセージを返信することが出来ます。
これを利用すると、例えば以下のようにして、
「元のメッセージのボタンを無効化しつつ、メッセージを返信する」といった動作が実現できます。
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        self.disabled = True
        await interaction.response.edit_message(view=self.view)
        await interaction.followup.send(content="OKボタンが押されました。")
2.7. レスポンスメッセージを編集したい場合
interaction.edit_original_responseを用いると、
インタラクションのレスポンスとして送信されたメッセージをあとから編集することが出来ます。
(参照: APIリファレンス)
例えば以下のように設定すると、
「10秒待機します。」と返信されたメッセージが、10秒後に「10秒経過しました。」に変化します。
import asyncio
...
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        await interaction.response.send_message(content="10秒待機します。")
        await asyncio.sleep(10)
        await interaction.edit_original_response(content="10秒経過しました。")
- 
ボタン押下時
   - 
10秒経過後
   
まとめ
今回は、メッセージにボタンを追加する方法と、ボタンを押した際の動作について詳しく見てきました。
次回はこれを利用して、「クイズ」機能の出題メッセージに回答ボタンを設置します。