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

今回は、チャットメッセージにインタラクティブなボタンを設置する方法を紹介します。
また、ボタンを押した際に実行可能な、様々な種類のレスポンスについて詳しく見ていきます。

Note
今回の記事は、Discordチャットボットのインタラクションに関する網羅的な解説記事とした為、
今までの記事に比べて分量が多めです。

Discordには、テキストメッセージの他に「メッセージコンポーネント」(または単に「コンポーネント」)
と呼ばれるインタラクティブな要素を追加できるフレームワークが用意されています。
(参照: Message Components)

この機能により、ボタンやセレクトメニュ等の様々なコンポーネントを
メッセージに設置することができるようになります。

discord.pyでは、これらの機能はdiscord.ui以下に実装されています。
(参照: Bot UIキット)

それでは早速、discord.uiを使用してメッセージにボタンを配置しましょう。

コンポーネントをメッセージに追加するには、はじめにdiscord.ui.Viewインスタンスを作成します。
そしてadd_itemメソッドを用いてViewインスタンスにコンポーネントを追加します。

ここからは、「OK」と書かれたボタン付きのメッセージを出力する!buttonコマンドを作っていきましょう。

  • commands/button.py

    from 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コマンドを実行してみましょう。
すると、以下のようにボタンが表示されるはずです。

!buttonコマンド実行結果

ボタンを押してみましょう。
するとまだ何も設定していないため「インタラクションに失敗しました」と表示されます。

ボタンを押した結果

それでは次に、ボタンを押した際の動作を定義しましょう。

discord.pyには、ボタン押下時の動作に関していくつか設定方法が用意されていますが、
今回はui.Buttonを継承した独自のクラスを作成する方法を紹介します。

はじめに、以下のようにui.Buttonを継承したOKButtonクラスを作成します。

class OKButton(ui.Button):
    ...

次に、インスタンスの初期化を行う__init__メソッドでは、
以下のようにlabelの初期値として「OK」を代入しておきます。

class OKButton(ui.Button):
    def __init__(self, *, label='OK', **kwargs):
        super().__init__(label=label, **kwargs)

次に、ボタンが押された際に実行されるcallbackメソッドを定義します(コルーチン関数です)。
callbackの第2引数には、発生したインタラクションを意味するInteraction型の値interactionが入ります。
(参照: APIリファレンス)

from discord import ui, Interaction
...
class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        ...

次に、レスポンスの種類について見ていきます。

Info

callbackメソッドでは、発生したinteractionに対して、レスポンスを1度だけ返す必要があります。
レスポンスは2度以上返すことは出来ないことに注意してください。
またレスポンスは3秒以内に返す必要があります。

(参照: APIリファレンス, Responding to an Interaction)

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を使用した場合)

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を使用した場合)

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を使用した場合)

最後に、作成した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)

チャットボットを起動して!buttonコマンドを入力し、表示されるボタンを押してみましょう。

interaction.response.send_messageを使用して2.3.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回目以降は「インタラクションに失敗しました」と表示され失敗するようになります。

一定時間経過後にインタラクションが失敗するようになるのは、ui.Viewインスタンスの初期化時に、
timeoutオプションがデフォルトで180秒(3分)に設定されていることが原因です。
無期限にしたい場合はNoneに設定してください。

@commands.command(name='button')
async def button_command(ctx: commands.Context):
    """ボタン付きメッセージを表示する."""
    view = ui.View(timeout=None)
    ...

interaction.response.send_messageを使用して2.3.2の設定を行った場合、
ボタンが設置してあるメッセージのテキストが「OKボタンが押されました。」に変更されます。

実行後にボタンをメッセージから消去したい場合、edit_messageviewオプションをNoneにすると実現できます。

class OKButton(ui.Button):
    ...
    async def callback(self, interaction: Interaction):
        await interaction.response.edit_message(content="OKボタンが押されました。", view=None)

実行後にボタンを無効化したい場合、以下のように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)

interaction.response.deferを使用して2.3.3の設定を行った場合、
以下のように10秒間「考え中…」となった後にメッセージが返信されます。

2.3節で以下のように書いた通り、1つのインタラクションに対するレスポンスは1つのみ可能です。

callbackメソッドでは、発生したinteractionに対して、レスポンスを1度だけ返す必要があります。
レスポンスは2度以上返すことは出来ないことに注意してください。

しかし、「元のメッセージのボタンを無効化しつつ、メッセージを返信する」といったような
複数の操作を行いたい場合があります。これを実現するにはどうすればよいでしょうか?

これは、interaction.followupを使用することで実現できます。
(参照: Followup Messages, APIリファレンス)

Info

interaction.followupはインタラクション発生後15分間有効です。
それ以上経過後に実行すると、以下のようなエラーが発生し失敗します。

discord.errors.HTTPException: 401 Unauthorized (error code: 50027): Invalid Webhook Token

(参照: Followup Messages)

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ボタンが押されました。")

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秒経過後

今回は、メッセージにボタンを追加する方法と、ボタンを押した際の動作について詳しく見てきました。
次回はこれを利用して、「クイズ」機能の出題メッセージに回答ボタンを設置します。

関連記事