ゲーマーに優しいdiscord botの実装パターン

この記事は [DeNA 21 新卒 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/dena-21-shinsotsu) の15日目の記事です。



#はじめに

いきなりですが、皆さんはdiscord使ってますか？

discordとはざっくり言うとゲーマー用のslackのようなもので、同じゲームをやっている人同士のボイスチャットや情報のやり取りに特化したツールです。

discordのサーバーではbotを導入することができ、ステージを選んでくれたりチーム分けをやってくれたりと、快適にゲームを行う上で便利な機能を持たせることが出来ます。


##ところが...

例えばランダムで対戦ステージを5つ選ぶためのコマンドが以下のようだったとします。

```:想定されたコマンド
/stage 5
```
シンプルな実装ですね。

さて、このコマンドをボイスチャットをしている友人に教えるにはどうすればいいでしょう？

---
友人「ステージをランダムに選んでくれるコマンドってどうやるんだっけ？」
ぼく「スラッシュ、ステージの後にスペース入れてステージ数入れればいけるよ」
友「スラッシュってどれ？」
ぼ「斜めの棒のやつ」
友「OK！入力したよ！あれ、動かない...」

```:入力されたコマンド
/stage　５
```
ぼ「（スペースと数字が全角になってるな...）あ、じゃあ代わりにぼくがやっとくよ〜」

---


このままだとこの友人はbotについて「入力が面倒だしなんだかよくわかんない難しいやつ」という印象を持ってしまうでしょう。

「理解している人がコマンド打てばいいじゃないか」と言われそうですが、そういった人が必ずしもその場にいるとは限らないですし、特定の人が打たなきゃいけないコマンドも場合によっては考えられます（自分の得点の記入など）。

そこで今回は、プログラミングに全く馴染みがないような人でも一人で扱えるようなdiscord botについて考えていこうと思います。




#動作環境

* Python 3.7.4
* pip 20.2
* discord.py 1.5.1




#botの導入

この記事の本題ではないので、詳しい点は[公式ドキュメント](https://discordpy.readthedocs.io/en/latest/)や[別記事](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f)を参考にしていただければと思います。

```bash:discord.pyのインストール
$ python3 -m pip install -U discord.py[voice]
```

なお、discord.py v1.5.0以降からはIntentsの設定が必要になります。[Discord Developer Portal](https://discord.com/developers/applications)から設定をしておきましょう。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/922324/a3266020-72fb-2d77-b312-950c47aa54ad.png)

ベースとなるコードは以下のとおりです。こちらもIntentsの設定の記述が必要になります。

```python:bot.py
import discord

TOKEN = 'write your token here'
#Intentsの設定
intents = discord.Intents.all()
client = discord.Client(intents=intents)

@client.event
async def on_ready():
    print('We have logged in as {0.user}'.format(client))

@client.event
async def on_message(message):
    # botからのメッセージは無視
    if message.author == client.user:
        return

    # 特定のコマンドが入力された時に返事をする
    if message.content.startswith('$hello'):
        await message.channel.send('Hello!')

client.run(TOKEN)
```
ざっくり説明すると、discord上で何かしらのメッセージが送信されると`on_message(message)`が呼ばれます。この例では`$hello`というメッセージが送信された場合のみ挨拶を返すようになっています。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/922324/dd7ba9dd-8498-3e32-8546-a5e0da2fbc99.png)

#ユーザーが操作しやすいbotとは

恐らく一番のハードルとなるのはスラッシュやバックスラッシュ、半角と全角の区別などの記号入力に関する部分ではないでしょうか。
スマホユーザーならなおさらですね。

そこで今回は**botの操作にあたって記号の入力が不要**ということを制約条件としてbotを実装していきます。

#実装パターン例

##botの呼び出し

手始めにbotの呼び出しをキーボードなしでできるようにしましょう。
discordでは絵文字が使えるのでこれを利用すれば簡単ですね。

```python:bot.py

@client.event
async def on_message(message):

    # 特定のコマンドが入力された時に返事をする
    if message.content.startswith('🤖'):
        await message.channel.send('Hello!')
```

![2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/922324/e9661b84-d204-9b6a-5674-89ef47f98b9e.gif)

注意点として、絵文字を使う場合には`':thumbsup:'`のような書き方はできません。
>Unicodeの絵文字を使用する場合は、文字列内の有効なUnicodeのコードポイントを渡す必要があります。 例を挙げると、このようになります。

>* '👍'
>* '\U0001F44D'
>* '\N{THUMBS UP SIGN}'

[公式ドキュメント](https://discordpy.readthedocs.io/ja/latest/faq.html#how-can-i-add-a-reaction-to-a-message)より引用

##機能の呼び出し

さて、botの呼び出しは簡単にできるようになったものの、まだ挨拶しかしてくれません。
そこでユーザーに選択肢を提示して好きな機能を呼び出せるようにしましょう。

`discord.py`には`wait_for()`関数が用意されているので、それを用いることでユーザーとの対話形式を実現することが出来ます。

```python:bot.py
@client.event
async def on_message(message):

    # 特定のコマンドが入力された時に返事をする
    if message.content.startswith('🤖'):
        #リアクション用Emojiリスト
        emoji_list = ['1️⃣', '2️⃣', '3️⃣']
        
        #メッセージとボタン代わりのリアクションを追加
        msg = await message.channel.send(
            'コマンドを選んでね\n' +
            '1️⃣便利な機能\n' +
            '2️⃣何かしらの機能\n' +
            '3️⃣良い感じの機能\n' )
        for add_emoji in emoji_list:
            await msg.add_reaction(add_emoji)

        #リアクションチェック用の関数
        #ここでは 「botを呼び出した本人が」 「botによって提示されたリアクションに反応した」 場合のみTrueを返す
        def check(reaction, user):
            return user == message.author and str(reaction.emoji) in emoji_list

        #リアクションが付けられるまで待機
        reaction, user = await client.wait_for('reaction_add', check=check)

        #付けられたリアクションに対応した機能を実装する
        if str(reaction.emoji) == (emoji_list[0]):
            #便利な機能を書く
            await message.channel.send('便利な機能ですね、わかりました！')

        if str(reaction.emoji) == (emoji_list[1]):
            #何かしらの機能を書く
            await message.channel.send('何かしらの機能ですね、わかりました！')

        if str(reaction.emoji) == (emoji_list[2]):
            #良い感じの機能を書く
            await message.channel.send('良い感じの機能ですね、わかりました！')
```

![3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/922324/f195e1e5-8dd1-7847-4d88-e5715581eff4.gif)


途中の`check(reaction, user)`関数は追加されたリアクションを判定する関数です。これを`wait_for()`関数の引数に入れることで特定のリアクションにのみbotを反応させることができます。

これでユーザーはリアクションをポチッと押すだけでbotの色んな機能を呼び出すことができるようになりました。



##リアクションの取得とロールの付与

discordではユーザーにロールを割り振ることができ、特定のロールにメンションを飛ばしたりボイスチャットの入室に制限をかけたりすることができます。

新しく入ってきたユーザーに管理者がいちいちロールを割り振るのは面倒なので、botを使ってユーザー自身にやってもらいましょう。

ユーザーがサーバーに入ってきてすぐにメッセージを見るとは限らないので、今回は`wait_for()`関数を使わずに`on_raw_reaction_add()`関数でいつリアクションを付けても処理されるようにしてみます。

```python:bot.py
# 各種ID
GUILD_ID = 01234
CHANNEL_ID = 12345
GAME_A = 23456
GAME_B = 34567
GAME_C = 45678
role_dict = {'🔴': GAME_A, '🔵': GAME_B, '🟢': GAME_C}

#サーバーに新しくメンバーが参加したときの処理
@client.event
async def on_member_join(member):
    #メンションをつけてメッセージを送信
    str = f'{member.mention}さん、こんにちは。\n現在プレイしているゲームに対応しているスタンプを選んでください！\n\nゲームA→🔴\nゲームB→🔵\nゲームC→🟢'
    msg = await client.get_channel(CHANNEL_ID).send(str)

    #候補となるリアクションを表示
    await msg.add_reaction('🔴')
    await msg.add_reaction('🔵')
    await msg.add_reaction('🟢')

#メッセージにリアクションが付いたときの処理
@client.event
async def on_raw_reaction_add(payload):
    #botのリアクションは無視する
    if payload.member.bot:
        return

    #リアクションに応じたロールのIDを取得
    role_id = role_dict[payload.emoji.name]

    if role_id is None:
        return

    #メンバーとロールを取得して付与
    member = client.get_guild(GUILD_ID).get_member(payload.user_id)
    role = client.get_guild(GUILD_ID).get_role(role_id)
    await member.add_roles(role)
```

![1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/922324/04a2d214-97fe-82e9-33bf-33e48af8c8e0.gif)

![4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/922324/fec6bfdd-63ee-165e-7ba9-4807a7ce5b04.gif)

これで新しくサーバーに入った人はメンションされたメッセージに対してリアクションを押すだけで適切なロールを振り分けてもらえます。

なお、各種IDはサーバー名やロール名を右クリックしてコピーできます。


##ページビュー的なもの

ページめくりができる形式でメッセージを表示したいときってありませんか？
例えばサーバーの利用規約であったり、試合の各ラウンドの結果だったり。

ということで、リアクションで操作できるページビュー的なものを実装してみます。



```python:bot.py
import asyncio

#今回は文面を予め用意
page_title = ['1回戦', '2回戦', '準決勝', '決勝']
page_desc = ['こんなことがありました', 'あんなこともありました', 'すごい展開でした', '激戦の末優勝したのは〇〇選手！']

@client.event
async def on_message(message):

    if message.content.startswith('📖'):
        #リアクション用Emojiリスト
        emoji_list = ['⏪', '⏩']
        #何ページ目かを表す変数
        page = 0

        #embedとボタン代わりのリアクションを追加
        embed = discord.Embed(title=page_title[page], description=page_desc[page])
        embed.set_footer(text=f'page {page+1} of {len(page_title)}')
        msg = await message.channel.send(embed=embed)
        for add_emoji in emoji_list:
            await msg.add_reaction(add_emoji)

        #リアクションチェック用の関数
        def check(reaction, user):
            #botを呼び出した本人からのリアクションのみ受け付ける
            #reaction.message == msg を入れないと複数出したときに全て連動して動いてしまう
            return user == message.author and reaction.message == msg and str(reaction.emoji) in emoji_list

        while True:
            try:
                #リアクションが付けられるまで待機
                reaction, user = await client.wait_for('reaction_add', timeout=15.0, check=check)
            except asyncio.TimeoutError:
                #一定時間経ったら消す
                await msg.delete()
                await message.delete()
                break;
            else:
                #付けられたリアクションに対応した処理を行う
                if str(reaction.emoji) == (emoji_list[0]):
                    #ページ戻し
                    #ページ数の更新(0~最大ページ数-1の範囲に収める)
                    page = (page - 1) % len(page_title)

                if str(reaction.emoji) == (emoji_list[1]):
                    #ページ送り
                    #ページ数の更新(0~最大ページ数-1の範囲に収める)
                    page = (page + 1) % len(page_title)

                #メッセージ内容の更新
                embed = discord.Embed(title=page_title[page], description=page_desc[page])
                embed.set_footer(text=f'page {page+1} of {len(page_title)}')
                await msg.edit(embed=embed)

                #リアクションをもう一度押せるように消しておく
                await msg.remove_reaction(reaction.emoji, message.author)
```

![5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/922324/525d9b1b-4383-4448-6fcc-1b6d507613b3.gif)


今回はユーザーからのリアクションが何回も来る可能性があるので、タイムアウトするまでwhile文で`wait_for()`を維持しています。また、タイムアウトするとユーザーからのリアクションを受け付けなくなるのでメッセージを消すようにしました。

Embed（埋め込みメッセージ）を使うことでページっぽくなってますね。


#おわりに

上の例みたいにbotの呼び出しに一般の絵文字を使うとスクロールしていちいち探さないといけないので、カスタム絵文字を使って探しやすくするとなお良さそうです。

ちょっとしたひと手間でbot操作がとても楽になるので、どの機能を実装するときもユーザー目線に立った開発を心がけていきたいですね。

皆さんもdiscord.pyで良きゲーマー生活をお送りください！


#宣伝

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ LGTM、Twitter や Facebook、はてなブックマークにてコメントをお願いします！

また [DeNA 公式 Twitter アカウント @DeNAxTech](https://twitter.com/DeNAxTech) では、 Blog 記事だけでなく色々な勉強会での登壇資料も発信しています。ぜひフォローして下さい！
<a href="https://twitter.com/intent/follow?screen_name=DeNAxTech">Follow @DeNAxTech</a>