discord.pyでソートなぞなぞBotを作った話(複数サーバ対応)

## はじめに
この記事は discord.py を用いた Discord bot 開発についての記事です。雑にCogを使うことを目的としています。あまり詳しい説明はしません。また、1つのBotインスタンスで複数サーバに対応させることをしています。参考になれば幸いです

## 更新箇所(2020/10/6)
- 記事の内容を大幅に更新

## 更新箇所(2019/12/18)
- Wikiコマンドのバグ修正
- 回答時にWikipediaのページを表示する

## ソートなぞなぞとは
初めに、ある単語をソートしたものが問題として与えられます。元になった単語を推測するゲームです。非自明な面白さがあります。

|問題|答え|
|:-:|:-:|
|えつぴん|えんぴつ|
|ペボルンー|ボールペン|
|aelpp|apple|

こちらからBotを招待することで遊ぶことができます
https://discord.com/api/oauth2/authorize?client_id=761582339557556245&permissions=2048&scope=bot

~~細かい話はどうでもいいから遊んでみたいという方は[こちら](https://github.com/t4t5u0/Sort_Riddle)からクローンして遊んでみてください。(ライブラリのインポートや、環境に合わせてファイルを多少いじる必要があります。)~~

## 動作環境
- Python 3.8.2
- discord.py 1.5.0

## Botアカウントの作成
[こちら](https://discordapp.com/developers/applications/)でBotアカウントを作成してください。その際にトークン(CLIENT SECRET)もコピーしておいてください。直後に使います。

## Botの作成と起動
GitHubのREADMEの通りです。`config.ini`にBotのトークンを置いておきます

```console:起動手順
$ git clone https://github.com/t4t5u0/sort-riddle-v2.git
$ cd sort-riddle-v2
$ echo -e '[TOKEN]\ntoken=Botのトークン'>config.ini
$ pip install -r requirements.txt
$ nohup python main.py &
```

## 以下実装の話

まずライブラリをインストールします

```
pip install discord
```


次にコードの解説です

```Python:sortriddle.py
import configparser

import discord
from discord.ext.commands import Bot


config = configparser.ConfigParser()
config.read('./config.ini')
TOKEN = config['TOKEN']['token']

bot = Bot(command_prefix='!')
bot.load_extension('cog.sort_riddle')


@bot.event
async def on_ready():
    # Bot起動時にターミナルに通知を出す
    print('-'*20)
    print('ログインしました')
    print('-'*20)
    await bot.change_presence(activity=discord.Game(name='!help'))


if __name__ == "__main__":
    bot.run(TOKEN)

```
`bot.load_extension`で読み込んだCogを追加していきます

最小構成は以下になります

```python:cog/sort_riddle.py
from discord.ext import commands


class SortRiddleCog(commands.Cog):

    def __init__(self, bot):
        self.bot = bot
        
    @commands.command()
    async def neko(self, ctx):
        await ctx.send(f'{ctx.author.mention} にゃーん')

def setup(bot):
    return bot.add_cog(SortRiddleCog(bot))
```
1. Cogのクラスを建てる
2. クラスを初期化する。Botを引数に取ること
3. コマンドを追加する。`commands.command()`デコレータを使用します
4. Cogを登録する。`setup`関数がないと動きません

![img](https://media.discordapp.net/attachments/761580345090113569/762696617072656394/unknown.png)

かわいいですね
***

## コマンド追加
このままではゲームとしては成り立たないのでコマンドを追加していきます。ソースは[こちら](https://github.com/t4t5u0/sort-riddle-v2/blob/develop/cog/sort_riddle.py)にあります。

|コマンド|内容|
|:--|:--|
|!start|出題|
|!answer|解答|
|!hint|答えの1文字目を表示|
|!giveup|ギブアップし答えを表示|

の4つを追加します

## 複数サーバへの対応

サーバ情報を保存するためのファイルを作ります。JSON配列に対しての探索は極力したくないので、探索用にサーバのIDのリストを持ちました。このリストに対して2分探索を行い、計算量の削減を行っています。現在は、小規模ということでJSONをメモリに持っていますが、将来的に規模が大きくなる場合はDBへの移行を考えています。csvモジュールはpandasに置き換えると高速化できそうです

```python:sort_riddle.py

import bisect
import csv
import json
import re
from datetime import datetime

import discord
import requests
from discord.ext import commands


class SortRiddleCog(commands.Cog):

    def __init__(self, bot):
        self.bot = bot
        self.sort_riddle_data = []
        self.guild_id_list = []

        with open('./data/guild_id_list.csv') as f:
            self.guild_id_list = [int(y) for x in csv.reader(f) for y in x]
        with open('./data/sort_riddle_data.json') as f:
            self.sort_riddle_data = json.load(f)
  
    @commands.Cog.listener()
    async def on_guild_join(self, guild):
        to_insert_index = bisect.bisect_left(self.guild_id_list, guild.id)
        info = {
            "guild_id": guild.id,
            "guild_name": guild.name,
            "channel_id": None,
            "answer": None,
            "question": None,
            "start_time": None
        }
        self.sort_riddle_data.insert(to_insert_index, info)

        with open('./data/sort_riddle_data.json', 'w') as f:
            json.dump(self.sort_riddle_data, f, indent=4)

        bisect.insort(self.guild_id_list, guild.id)
        with open('./data/guild_id_list.csv', 'w') as f:
            writer = csv.writer(f)
            writer.writerow(self.guild_id_list)


    @commands.Cog.listener()
    async def on_guild_remove(self, guild):
        to_delete_index = bisect.bisect_left(self.guild_id_list, guild.id)

        self.sort_riddle_data.pop(to_delete_index)
        with open('./data/sort_riddle_data.json', 'w') as f:
            json.dump(self.sort_riddle_data, f, indent=4)

        self.guild_id_list.pop(to_delete_index)
        with open('./data/guild_id_list.csv', 'w') as f:
            writer = csv.writer(f)
            writer.writerow(self.guild_id_list)
```

`commands.Cog.listner()`デコレータでイベントを拾っていきます。Botがサーバに入った時に情報を追加する処理と、退出した時に削除する処理を書いています。

<details><summary>startコマンドの実装</summary><div>

```python:sort_riddle.py>start
    @commands.command(aliases=['s'])
    async def start(self, ctx):

        if 'guild' not in dir(ctx.author):
            await ctx.send('**!start** はDM限定だにゃ')
            return

        # guild_id が登録されていなかったときの処理

        index = bisect.bisect_left(self.guild_id_list, ctx.author.guild.id)
        if ctx.author.guild.id not in self.guild_id_list:
            info = {
                "guild_id": ctx.author.guild.id,
                "guild_name": ctx.author.guild.name,
                "channel_id": None,
                "answer": None,
                "question": None,
                "start_time": None
            }
            self.sort_riddle_data.insert(index, info)

            with open('./data/sort_riddle_data.json', 'w') as f:
                json.dump(self.sort_riddle_data, f, indent=4)

            bisect.insort(self.guild_id_list, ctx.author.guild.id)
            with open('./data/guild_id_list.csv', 'w') as f:
                writer = csv.writer(f)
                writer.writerow(self.guild_id_list)

        if (q := self.sort_riddle_data[index]['question']) is not None:
            await ctx.send(f'問題は **{q}** だにゃ')
            return

    
        link = 'https://ja.wikipedia.org/w/api.php?action=query&list=random&format=json&rnnamespace=0&rnlimit=1'
        response = requests.get(link)
        json_data = response.json()

        """
        json_data
        {'batchcomplete': '',
            'continue': {'continue': '-||',
                        'rncontinue': '0.269787212577|0.269787813864|1126887|0'},
            'query': {'random': [{'id': 2226876,
                                'ns': 10,
                                'title': 'Template:Country alias 岐阜県'}]}}
        """

        a = json_data['query']['random'][0]['title'].replace(' ', '_')
        self.sort_riddle_data[index]['answer'] = a
        q = ''.join(sorted(list(a)))
        self.sort_riddle_data[index]['question'] = q
        await ctx.send(f'問題は **{q}** だにゃ')

        self.sort_riddle_data[index]['start_time'] = re.split('[-|:|.|\s]',str(datetime.now()))
        # 諸々を書き込み
        with open('./data/sort_riddle_data.json', 'w') as f:
            json.dump(self.sort_riddle_data, f, indent=4)
```

時刻を取るところは、実装し終わったあとにライブラリに関数があることに気づきました。(アホ) 早期リターンを心がけました

</div></details>

## おわりに
~~初投稿です。また、プログラミング歴半年足らずの初心者です。ガバがありましたら容赦なくマサカリを投げつけてください。~~
初投稿から1年経ったのをキッカケにリファクタリングをしてみました。過去の自分が書いたプログラムって見たくないものですね…

ソースは[こちら](https://github.com/t4t5u0/sort-riddle-v2)に置いてあります。ぜひ。
## 参考にした記事・サイト
[discord.py へようこそ。 — discord.py 1.3.0a ドキュメント](https://discordpy.readthedocs.io/ja/latest/index.html#)
[Pythonで実用Discord Bot(discordpy解説) - Qiita](https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f)
[discord.pyのBot Commands Frameworkを用いたBot開発 - Qiita](https://qiita.com/Lazialize/items/81f1430d9cd57fbd82fb)