かわいい女の子に本を探してもらいたいをSlackのBotとPythonで実現する(1)

今回ものすごくぶっ飛んだタイトルですが、やはり技術に対するモチベーションというのは
こんな些細な下心から始まると勝手に信じています。

---

##何がしたいか
可愛い女の子に好き勝手したい！！！下記概要参照

一問一答ではなく自然言語で理解して本を探してきてくれるちょっと気が利く紗希ちゃんBot(超理想)
　LUISとかDialogFlowを絡ませるといい感じにできそう。

今回はこっち！
一問一答以前にコマンドと引数を渡すと本を調べてきてくれるちょっと気が利く紗希ちゃんBot(現実)
　Pythonアプリをホストしてあげればどこでも動く。


むむむ

今回ほぼ初めてプログラムを書いたので思うような実装ができませんでしたが
最低限実装していくうえでのメモを書いていきたいと思います。

---

##概要 （そんなことはそんなに大事じゃないので）
今回のSlackのBotの概要です。

###↓こういうのを実現したい
![ezgif-1-0481fd3a2d.gif](https://qiita-image-store.s3.amazonaws.com/0/227531/4f4fa6da-e33d-9e49-86bc-cc38e3610e35.gif)


###ざっくりイメージ
![image.png](https://qiita-image-store.s3.amazonaws.com/0/227531/012b5fd9-0e43-8e8f-4ca8-4d90595e68f5.png)


##開発環境
- Windows10 PC　(レッツノート)
- VScode (pythonの拡張機能)
- Python別でC直下かどこかにインストールしました。
- 楽天API
- slackclient(PythonのSlack公式SDK？的なもの)


開発環境エディタとかはそれぞれ好みがあるので取り上げませんが
VScode(Visual Studio Code)は軽くて扱いやすかったです。

楽天APIもアカウント作ればすぐに使うことができました。
https://webservice.rakuten.co.jp/document/

Slackも大丈夫ですよね。
▼公式ドキュメント
        http://slackapi.github.io/python-slackclient/conversations.html
        https://github.com/slackapi/python-slackclient

一番最初にSlackbotという 大変すばらしい ライブラリを使ってみたのですが、 あんまり
出来が良すぎてすることがなくなってしまうため、今回はあえて使いませんでした。
先にSlackbotで実装してから、同じことをSlackbotなしで実装したのですが、めちゃくちゃ大変でした。（主観）

##プログラムを1から記述するのは初めてだから困ったことが頻発

- Slackの公式ドキュメントがみんな英語だった
- サンプルコードがHogeとFizzだのBuzzだのよくわからない
- 「どこまでがコードでどこからが任意で指定できる値なのか」
- IDE の使い方がわからない。

まぁ全部ググって解決しましたけど。
そしてQiitaと公式ドキュメントを読み漁りました。
先輩も口を酸っぱくして教えてくれましたが、公式ドキュメントはめっちゃ大事！

そんなわけで前途多難なBot開発の旅が始まります。
ざっくりですがSlackのBot制作の参考になれば幸いです。


##実装内容その１
###SlackのmessageをBotが読み漁る。
※これを行うにはあらかじめ、botをchannelに追加する必要があって追加されていないチャンネルの会話はbotが取得することはできません。
※その瞬間のSlackのmessageを取得しますが、誰も書き込んでいない場合は空の値が返ってきます。

```python:qiita.py
from slackclient import SlackClient
import time

#BotのAPIトークンを"SLACK_API_TOKEN"入力
slack_token = "SLACK_API_TOKEN" 

def readslack():
    sc = SlackClient(slack_token)
    #疎通確認
    sc.rtm_connect()
    #ループで追あ回すため変数を初期化
    rtm_read_jsondata = None
    rtm_read_jsondata = sc.rtm_read()
    #1秒待つAPI
    time.sleep(1)
    return (rtm_read_jsondata)


#実行結果を表示するだけ
print (readslack.rtm_read_jsondata)
    
#実行結果(メッセージが飛んできた時)
[{'type': 'message', 'channel': 'XXXXXXXXX', 'user': 'XXXXXXXXX', 'text': '投稿されたメッセージ', 'ts': '1X202XXX49.0XXX63', 'source_team': 'XXXXXXXXX', 'team': 'XXXXXXXXX'}]

```
初回実行時は'type':'hello'になるため注意する。
二回目以降は空の値が返ってくる。
またユーザーがmessageボックスに↓　フォーカスしている場合は、'type': 'user_typing'　になるので、エスケープ処理を入れる場合は工夫が必要です。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/227531/bbf272b6-1426-4b9f-a062-f495af3cfffa.png)


```python:エスケープの例.py
#Slack を読んだ際に受信したメッセージのTypeを判別する。
#rtm_read_jsondataには
[{'type': 'message', 'channel': 'XXXXXXXXX', 'user': 'XXXXXXXXX', 
'text': '投稿されたメッセージ', 'ts': '1X202XXX49.0XXX63', 'source_team': 'XXXXXXXXX', 
'team': 'XXXXXXXXX'}]
#が格納されている前提。

def message_escape(rtm_read_jsondata):
    #jsonの中身を検証する。
    if rtm_read_jsondata == []:
           time.sleep(1)
           print('nodata!')
           time.sleep(1)
           pass
    #Typeがhelloやuser_typingならループ
      else:
          if rtm_read_jsondata[0]['type'] in ['hello', 'user_typing']:
                #再度メッセージを読み漁らせる処理を記述する
                
            else:
                print(rtm_read_jsondata[0]['text'])

                return rtm_read_jsondata[0]['text']

    
#実行結果(メッセージが飛んできた時)
'投稿されたメッセージ' 

```


##Bot自身のmessageは無視するようにする。
このままではBot自身の発言にも反応してしまうため。

```python:Bot自身の発言は無視する処理の例.py
#Slack を読んだ際に受信したメッセージのTypeを判別する。
#rtm_read_jsondataには
[{'type': 'message', 'channel': 'XXXXXXXXX', 'user': 'XXXXXXXXX', 
'text': '投稿されたメッセージ', 'ts': '1X202XXX49.0XXX63', 'source_team': 'XXXXXXXXX', 
'team': 'XXXXXXXXX'}]
#が格納されている前提。

def bot_message_escape(rtm_read_jsondata):
    #jsonの中身を検証する。
    if rtm_read_jsondata == []:
           time.sleep(1)
           print('nodata!')
           time.sleep(1)
           pass
    #Typeがhelloやuser_typingならループ
      else:
          if rtm_read_jsondata[0]['type'] in ['hello', 'user_typing']:
                #再度メッセージを読み漁らせる処理を記述する
              else:
                print(rtm_read_jsondata[0]['text'])

                #ここは送られてくるフィールドの値があいまいなため例外処理しないと絶対止まる
                #'user'がある場合とない場合がある
                try:
                    userid = rtm_read_jsondata[0]['user']
                    print(userid)
                except:
                #エラーになった場合はもう一回messageを取得させに行く
                    saki_readslack()

                #U8XXXXX32は紗希ちゃんのuseridでuseridがかぶっていたらmessageを再取得させて無視したことにする。
                #ここのところはもっといい感じの書き方があると思っている。
                if userid == 'U8XXXXX32':
                    saki_readslack()
                else:
                    print(rtm_read_jsondata)
                   #結果を返すか、後続の処理を記述する。 
                return rtm_read_jsondata[0]['text']

#実行結果(メッセージが飛んできた時)
'投稿されたメッセージ' 

```

##messageを解釈する。
送られてくるメッセージはコマンドが含まれていて、コマンドとキーワードを分割する。
例えば
![image.png](https://qiita-image-store.s3.amazonaws.com/0/227531/16d271c9-1dec-9956-f27a-bebad7decb95.png)

こんな感じに　"\$books [半角スペース]　ろんぐらいだあす！"　のように
コマンドとして"\$books"
引数として"本のタイトル"
として扱うことで単純に処理しやすいです。

※ここも実装の仕方によりけりだとは思いますが。Slackでわざわざ口語で命令するってあんまり意味ないと思ったのでコマンド方式を採用しました。

実装はめっちゃシンプル

```python:メッセージを解釈してコマンドだったら分割する.py

def splitmessages(message):
    #split()は文字列をいい感じに空気を読んで分割して配列にしてくれる関数
    #messageの中身は'$books ろんぐらいだあす！'とする。
    split_msg = message.split()
    #ここは"$books"のコマンド含まれているかを検証している
    #含まれていない場合は別の何かの処理をさせていい感じにしておく。
    if split_msg[0] == "$books" or split_msg[0] == "＄Books":
        #本のキーワードを返す。
        return(split_msg[1])
    else:
        return 0
```

##本を検索する。これもめっちゃシンプル
  PayloadはAPIに投げるGetリクエストの内容を定義するよ
  検索のトップに出てくるものを結果として出力するよ
  ▼参照：https://webservice.rakuten.co.jp/api/booksbooksearch/


```python:本を検索する.py
import requests

def search_book(Keyword):
   #Getとして投げるURLをJSON形式で定義します。
   #'applicationId'はあらかじめ楽天ウェブサービスに登録して発行しておきましょう。
   payload = {'applicationId': 'XXX14XX5XXXXXXX2345',
               'format': 'json',
               'formatVersion': '2',
               'title': Keyword,
               'hits': '1', 'page': '1'}

   res = requests.get('https://app.rakuten.co.jp/services/api/BooksBook/Search/20170404?', params=payload).json()

#検索結果1位の結果がJSONで返ってくる。
#実行結果というかJSONの中身



```
#実行結果というか戻ってくるres(JSON)の中身
```python:resの中身
{
 'count': 26, 'page': 1, 'first': 1, 'last': 1, 'hits': 1, 'carrier': 0,
 'pageCount': 26,
 
 'Items':
  [
 {
  'title': 'ろんぐらいだぁす！（9）',
  'titleKana': 'ロングライダァス',
  'subTitle': '',
  'subTitleKana': '',
  'seriesName': 'IDコミックス\u3000REXコミックス',
  'seriesNameKana': 'アイディー コミックス レックス コミックス',
  'contents': '',
  'author': '三宅大志',
  'authorKana': 'ミヤケ,タイシ',
  'publisherName': '一迅社',
  'size': 'コミック',
  'isbn': '9784758066730',
  'itemCaption': '',
  'salesDate': '2017年07月27日',
  'itemPrice': 619,
  'listPrice': 0,
  'discountRate': 0,
  'discountPrice': 0,
  'itemUrl': 'https://books.rakuten.co.jp/rb/15005631/',
  'affiliateUrl': '',
  'smallImageUrl':'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/6730/9784758066730.jpg?_ex=64x64',
 'mediumImageUrl':
 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/6730/9784758066730.jpg?_ex=120x120', 
 'largeImageUrl': 
 'https://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/6730/9784758066730.jpg?_ex=200x200',
  'chirayomiUrl': '',
  'availability': '1',
  'postageFlag': 0,
  'limitedFlag': 0,
  'reviewCount': 0,
  'reviewAverage': '0.0', 
  'booksGenreId':'001001002018/001001002024/001001003043'
 }
  ], 'GenreInformation': []}
```


##Jsonが入れ子になっているときのデータを取り出す方法
本の検索結果を応答メッセージとして整形するのですが。
Jsonが入れ子になってる仕様に踊らされてしまったので、ちょっと書きます。
(フォーマットバージョン２の方が扱いやすい気がします)
基本的なJSONの構造としては
検索結果として何も見つからなかった場合、”count”:"0"になって帰ってくるので、例外処理も忘れずに入れます。

```python:返答されたJSONの構造例

{
 'count': 26, 'page': 1, 'first': 1, 'last': 1, 'hits': 1, 'carrier': 0,
 'pageCount': 26,
 
 'Items':
  [
 {
  'title': 'ろんぐらいだぁす！（9）',
  'titleKana': 'ロングライダァス',
  'subTitle': '',
  'subTitleKana': '',
   ......
 }
]

```

このようなデータ配列になっているため
データを取り出すときは↓ような感じで階層ごとに変数に入れて配列を
掘っていくとわかりやすいかもしれないです。

```python:JSONの要素取り出し例
item = res['Items'][0]
item_title = item['title']

```

検索結果の取得ができたところで
データを整形します。

```python:検索データ整形
#検索結果を１つ１つ投稿させるのでも問題はありませんが、一度にBotから大量に通知が来るため、改行コード含めて1行にする。

search_book_result = [result_title, result_author,
                          result_publisher, result_sale, "楽天で見る" + result_itemurl]
#配列に入った検索結果を\n区切りで結合する。
search_book_result = '\n'.join(search_book_result)

```

###※たくさん通知来る例
![image.png](https://qiita-image-store.s3.amazonaws.com/0/227531/8d2c5e19-e4fe-0ba2-f47b-2d8ae4d24a79.png)


各行ごとに投稿されている状態

###※通知が１つで済む例(こっちを採用) 
![image.png](https://qiita-image-store.s3.amazonaws.com/0/227531/f7aac9e7-fc96-2f72-c573-10012c571184.png)

messageボックスの中でshift+Enterで改行しているイメージ


##Botからメッセージを投稿する。

```python:dispatchメッセージ.py
#引数は投稿するメッセージとチャンネルIDです。
def postsmessage(message, channelID):
    slack_token = "XXXXXXXXXXXXXX"
    saki_bot_id = "XXXXXXXXXXXXXX"
    rescall = SlackClient(slack_token)
#何らかの理由で失敗してもいいように例外処理を入れておく。
    try:
#as_user="true"にしないとアイコンや名前が表示されない仕様。
        rescall.api_call("chat.postMessage",
                         channel=channelID,
                         as_user="true",
                         username=saki_bot_id,
                         text=message,
                         )

    except:
        print(rescall)
        return 1
    return 0
```


##まとめ
本当はクラス分けしたほうがいいのかもしれませんが、そこまで手が回りませんでした。
機能とかコマンド追加するときはクラス化して一つ追加するだけでどうにかできる設計にしたいです。
頻繁に仕様が変更になる雰囲気がプンプンなので、本番で使う場合は定期的な情報のキャッチアップが必要だと感じました。
初心者としては毎日何かかしら触るとエラーだったり英語のドキュメントに対する抵抗がどんどん薄くなるので
継続は大事だなと思います。

##次回予告
自然言語処理に対応したり Google homeに対応したりいろいろ楽しいことができそうだと思いました。
可愛い女の子の声とアバターは関係各所にお願いしてどうにか解決します。Live２Dとかめっちゃ大変だと思うけど。
(バーチャルユーチューバー的な可愛いのを作りたいですね！)

これまでの進捗というかソースコードはこちらにUPしています↓
https://github.com/KurataAmi/smart_saki_bot