Discord bot 「雲さん」を作ってみた

# 追記
2019/6/17 現在「雲さん」の機能は11個ほどに増えています。
2019/12/6 discord APIにメジャーアップデートが入りましたね。
**本記事で紹介している内容は`discord.py 0.16.12`当時のプログラムになります。
`discord.py 1.2.4`版のソースコードに関してはGitHubにて公開していますので、そちらをご参照ください。**

ソースコードはこちら：[KUMOSAN - GitHub](https://github.com/TakaseIkegami/KUMOSAN)

# はじめに
以前、Slack botで同様のものを作ったのですが、より多くの友人に遊んでもらいたいなということで、Discord版へ移植したbotのご紹介です。
Slack botではAWS Lambdaを使用して動作させていましたが、今回はVPSレンタルサーバ上にコードを載せます。

# サーバ動作環境
```
- Ubuntu 18.04.2 LTS (GNU/Linux 4.15.0-50-generic x86_64)
- python 3.6.0  
(/home/USERNAME/.pyenv/version)
- pip 19.0.3
  - discord.py 0.16.12
  - wikipedia 1.4.0
  - requests 2.21.0
  - urllib3 1.22
  - beautifulsoup4 4.6.0
```

# 開発者アカウントの登録
Discord botを扱うためには[Discord Developer Portal](https://discordapp.com/developers/applications/)から、アカウントの登録が必要です。
作成したアカウントを導入したいDiscordサーバに登録し、アクセストークンを取得してください。

- 詳しくは以下等の他筆者様の記事をご参照下さい。  
  - [Discord Botアカウント初期設定ガイド for Developer - Qiita](https://qiita.com/1ntegrale9/items/cb285053f2fa5d0cccdf)

# モジュールの導入
pipから以下のようにして、botで使いたいモジュールを導入します。

```requirements.txt
discord.py==0.16.12
wikipedia==1.4.0
requests==2.21.0
urllib==1.22
requests==2.0.1
beautifulsoup4==4.6.0
```
`$ pip install -r requirements.txt`

# 機能
今回実装したbotの機能は以下のとおりです。
- 静的な受け答え
- 指定都市の天気情報の取得
- wikipedia検索による指定ワードの情報取得
- Miller-Rabin高速素数判定法による巨大素数の判定([GitHub - PrimalityTest](https://github.com/TakaseIkegami/PrimalityTest/blob/master/miller-rabin/miller-rabin_for3.py))

# プログラム本体
では、早速コード本体を書いていきましょう。
- 「ここにトークンを書いてね」のところを上記([開発者のアカウント登録](https://qiita.com/drafts/dfcdd422be46ffc311aa#%E9%96%8B%E7%99%BA%E8%80%85%E3%82%A2%E3%82%AB%E3%82%A6%E3%83%B3%E3%83%88%E3%81%AE%E7%99%BB%E9%8C%B2))で取得したアクセストークンに置き換えて下さい。
- `$ python kumosan.py` で動くので、実行後、導入したDiscordの適当なチャンネルで`雲さん おはよ！`等声をかけてみて下さい。反応があったら成功です。

```kumosan.py
import discord
import wikipedia
import requests
import json
import random

client = discord.Client()
TOKEN = "ここにトークンを書いてね"

@client.event
async def on_ready():
    print('Logged in as')
    print(client.user.name)
    print(client.user.id)
    print('------')

@client.event
async def on_message(message):
    # 「雲さん」で始まるか調べる
    if message.content.startswith("雲さん"):
        # 送り主がBotだった場合反応したくないので
        if client.user != message.author:
            try:
                user_name = message.author.name
                text = message.content
                print(text)
                print(type(text))

                ################# Don't touch. ################
                kumo_san = '╭◜◝ ͡ ◜◝╮ \n(   •ω•　  ) \n╰◟◞ ͜ ◟◞╯ < '
                ################# Don't touch. ################

                msg =  kumo_san + user_name + 'さん '
                #msg = user_name + 'さん '

                if text == ('雲さん'):
                    msg = 'はい！ご用でしょうか！'
                elif text.find('おは') > -1:
                    msg += 'おはようございます！'
                elif text.find('こんにちは') > -1 or text.find('こんちゃ') > -1 or text.find('やあ') > -1 or text.find('おっす') > -1:
                    msg += 'こんにちは！'
                elif text.find('こんばん') > -1 or text.find('ばんわ') > -1:
                    msg += 'こんばんは！'
                elif text.find('おつ') > -1 or text.find('疲') > -1 or text.find('お先') > -1 or text.find('おち') > -1 or text.find('落ち') > -1:
                    msg += 'おつかれさまです〜'
                elif text.find('自己紹介して') > -1:
                    msg = 'はい！はじめまして。Takaseさんに作られたヒヨッ子botの雲と申します。趣味は素数を数えることです。皆さんのお役に立てすようにがんばりますので、どうぞよろしくお願いします！:cloud:'
                elif text.find('help') > -1 or text.find('-h') > -1:
                    msg += 'https://discordapp.com/channels/407045885281828877/407050154315874315/558382007433035786'
                elif text.find('って何') > -1:
                    msg += wikipediaSearch(text)
                elif text.find('天気') > -1:
                    msg += getWeatherInformation(text)
                elif text.find('は素数') > -1:
                    msg += primarity_test(text, 50)
                else:
                    msg += 'その言葉は知らなかったから調べたよ。\n' + wikipediaSearch(text)
                # メッセージが送られてきたチャンネルへメッセージを送ります
                await client.send_message(message.channel, msg)
                return msg
            except Exception as e:
                print(e)
                raise e

# Get Weather Infomation
def getWeatherInformation(text):
    weather_api_url = 'http://weather.livedoor.com/forecast/webservice/json/v1'
    response_string = ''
    city_id = ''
    if text.find('長野') > -1:
        city_id = '200010'
    elif text.find('大阪') > -1:
        city_id = '270000'
    elif text.find('東京') > -1:
        city_id = '130010'
    elif text.find('北海道') > -1 or text.find('札幌') > -1:
        city_id = '016010'
    elif text.find('愛知') > -1 or text.find('名古屋') > -1:
        city_id = '230010'
    elif text.find('佐賀') > -1:
        city_id = '410010'
    else:
        city_id = '130010'
        response_string += '場所が聞き取れなかったので取り敢えず'

    try:
        params = {'city':city_id}
        response = requests.get(weather_api_url,params=params)
        response_dict = json.loads(response.text)
        title = response_dict["title"]
        description = response_dict["description"]["text"]
        response_string += title + "です！\n\n"
        forecasts_array = response_dict["forecasts"]
        forcast_array = []
        for forcast in forecasts_array:
            telop = forcast["telop"]
            telop_icon = ''
            if telop.find('雪') > -1:
                telop_icon = ':showman:'
            elif telop.find('雷') > -1:
                telop_icon = ':thunder_cloud_and_rain:'
            elif telop.find('晴') > -1:
                if telop.find('曇') > -1:
                    telop_icon = ':partly_sunny:'
                elif telop.find('雨') > -1:
                    telop_icon = ':partly_sunny_rain:'
                else:
                    telop_icon = ':sunny:'
            elif telop.find('雨') > -1:
                telop_icon = ':umbrella:'
            elif telop.find('曇') > -1:
                telop_icon = ':cloud:'
            else:
                telop_icon = ':fire:'

            temperature = forcast["temperature"]
            min_temp = temperature["min"]
            max_temp = temperature["max"]
            temp_text = ''
            if min_temp is not None:
                if len(min_temp) > 0:
                    temp_text += '\n最低気温は' + min_temp["celsius"] + "℃"
            if max_temp is not None:
                if len(max_temp) > 0:
                    temp_text += '\n最高気温は' + max_temp["celsius"] + "℃"

            forcast_array.append(forcast["dateLabel"] + ' ' + telop + telop_icon + temp_text)
        if len(forcast_array) > 0:
            response_string += '\n\n'.join(forcast_array)
        response_string += '\n\n' + description
    except Exception as e:
        response_string = '天気検索でエラーです＞＜ :cold_sweat:\n' + e.message + '\n' + str(e)
    return response_string

# Search from Wikipedia
def wikipediaSearch(text):
    response_string = ''
    wikipedia.set_lang('ja')
    index_st = text.find(' ')
    index_ed = text.find('って何')
    search_text = text[index_st:index_ed]
    search_response = wikipedia.search(search_text)
    print(search_response)
    if len(search_response) > 0:
        try:
            wiki_page = wikipedia.page(search_response[0])
        except Exception as e:
            try:
                wiki_page = wikipedia.page(search_response[1])
            except Exception as e:
                response_string = 'お探しの言葉ではエラーを起こしました！:cold_sweat:\n' + e.message + '\n' + str(e)
        response_string = '説明しよう！\n'
        response_string += wiki_page.content[0:200] + '.....\n'
        response_string += wiki_page.url
    else:
        response_string = '今はまだ見つけられないンゴ…でも頑張って見つけられるようになるゾ〜！'

    return response_string

# Miller-Rabin Primality Test
def primarity_test(text, k):
    response_string = ''
    index_st = text.find(' ')
    index_ed = text.find('は素数')
    q = int(text[index_st:index_ed])
    if q == 2:
        response_string = str(q) + 'は素数です！:laughing:'
        return response_string
    if q < 2 or q & 1 == 0:
        response_string = str(q) + 'は素数じゃないです！:rage:'
        return response_string

    d = (q - 1) >> 1
    while d & 1 == 0:
        d >>= 1

    for i in range(k):
        a = random.randint(1, q-1)
        t = d
        y = pow(a, t, q)
        while t != q-1 and y != 1 and y != q-1:
            y = pow(y, 2, q)
            t <<= 1
        if y != q-1 and t & 1 == 0:
            response_string = str(q) + 'は素数です！:smile:'
        response_string = str(q) + 'は素数です！:wink:'
    return response_string

client.run(TOKEN)
```
- ソースコードはGitHubからもダウンロードいただけます。[GitHub - KUMOSAN](https://github.com/TakaseIkegami/KUMOSAN)


# 使い方
- 雲さん おはよ！
<img width="451" alt="スクリーンショット 2019-06-15 11.57.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82393/7c09e2f4-af3a-c274-356b-49b5e084bc6d.png">

- 雲さん 東京の天気教えてー
<img width="852" alt="スクリーンショット 2019-06-15 11.57.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82393/baf0a9eb-6f50-18e8-8e9a-ebebcc875d33.png">

- 雲さん RWBYって何？
<img width="854" alt="スクリーンショット 2019-06-15 11.59.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82393/4ccbf8b8-3a73-35f3-6ba3-e2e7ca43af83.png">

- 雲さん 6700417は素数？
<img width="456" alt="スクリーンショット 2019-06-15 11.57.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82393/80714b1d-8c43-e4ee-a4de-db72ab4f539e.png">

かわいい

# 最後に
この記事を書いている現在では、雲さんの機能がいくつか増えているので、今回ご紹介した機能以外についてもまた記事にできたらなと思っています。
Discordに導入して稼働させていると、友人たちにいっぱい遊んでもらえてとても嬉しいですね。

# 参考
- [slackのbotに天気を教えてもらう(Python on AWS Lambda + API Gateway) - ヤマムギ](https://www.yamamanx.com/slack-bot-weather-python-on-aws-lambda-api-gateway/)
- [Discord Botアカウント初期設定ガイド for Developer - Qiita](https://qiita.com/1ntegrale9/items/cb285053f2fa5d0cccdf)
- [livedoorお天気Webサービス](http://weather.livedoor.com/weather_hacks/webservice)

>お天気Webサービス（Livedoor Weather Web Service / LWWS）は、現在全国142カ所の今日・明日・あさっての天気予報・予想気温と都道府県の天気概況情報を提供しています。
引用：http://weather.livedoor.com/weather_hacks/webservice