ちゃま語でツイートするSlack Bot 〜AWS Lambdaを用いたSlack Botの作り方〜

# はじめに
## こんな人に読んでもらいたい
ネタはホロライブ関連だけど、やってることは以下のようなことなので、役に立つかも(役に立ってほしい)。

・AWS Lambdaを用いてSlack Botを作る
・Botとの個人チャットに送信された文章を取得して、定められた動作する。(返信もする)
・twitterAPIでツイート
・AWS Lambdaをserverless-frameworkでデプロイ
・Python外部モジュールはserverless-python-requirementsで導入

## これは...?

　ちゃま語でツイートするSlack Botを作った。ざっくり説明すると、

1. Slack Botにツイートしたい文を入力する
2. ちゃま語に翻訳する
3. 翻訳した文をツイートする

## そもそも「ちゃま語」って...?(最初にこれ書けよ

　先日、マリン船長が配信で、**「ちゃま語であそぼ」**なるものを、はあちゃまが提案していたという旨の話をしていた。(以下の動画を参照)
https://www.youtube.com/watch?v=T2yMNE_zb54
https://www.youtube.com/watch?v=IoOeMaCzuZY

ちゃま語というのは、ざっくりいうと**文中の「と」を「ちゃま」に置換**したものだという。
例えば、

「赤井はあと」->「赤井はあちゃま」
「トマト」->「ちゃままちゃま」
「となりのトトロ」->「ちゃまなりのちゃまちゃまろ」

そして、「ちゃま語であそぼ」というのは以下のようなルールのローテーションリズムゲームらしい。

1. 1人目が「と」を含む単語のお題を言う
2. 2人目がお題をちゃま語にする(「と」を「ちゃま」にして言う)
3. 3人目は1.と同じく「と」を含む単語のお題を言う。以下繰り返し。

配信を見ていた私は、ミーム汚染されていく船長とコメント欄を見ながら大爆笑しつつ、「『ちゃま語でツイートするSlack Bot』でも作って、『AWS Lambdaを用いたSlack Botの作り方』の記事を書くか〜〜」と考えた次第だ。

*[追補]
以前、私は仕事で社内の請求書作成自動化Botを作った。で、その話を記事にしようと思った。(システムが扱うデータは勿論機密だけど)実装方法自体は機密ではないから…とはいえ、社内のシステムを記事にするのはちょっと…ねぇ…。一旦保留した。で、今回ちょうどいいネタが見つかったので、それを使って『AWS Lambdaを用いたSlack Botの作り方』を記事にした。
(Googleスプレッドシートの編集の自動化とか、「請求書作成自動化Bot」で使った他のことの話は後日別記事に書こうかなと思う。)*

# 本題
## はじめる前に
Twitter Developerアカウントを申請・取得しておいてくださいな。(今回の作業でこれが一番めんどくさい)

## serverless-frameworkでLambdaをデプロイ

まずは、serverless-frameworkを用いて、chama-language-tweet-botというLambdaを作る。
(serverless-frameworkが入ってない人は ```npm install -g serverless``` して入れておいて)
(awsのアカウントを設定していない人はやっておいて。「aws-cli 使い方」とか調べれば出てくるから、キーを設定して。)

```
$ mkdir chama-language-tweet-bot
$ cd chamago-tweet-bot
$ npm init
$ serverless create --template aws-python3 --name chama-language-tweet-bot
$ ls
handler.py        package-lock.json    serverless.yml
node_modules      package.json
```

ここまではお決まりの流れだね。(ここで、```sls deploy``` してちゃんと設定できているか確認してみても良いかと)
あ、私の趣味だけど、handler.pyではわかりにくいので、ここでファイル名をslackbot.pyに変更した。今回は1つの関数しか作らないからhandler.pyのままでも問題ないが、大抵1つのLambdaに複数の関数を置いたりするので、handler.pyでは分かりにくすぎる。そこで、私は普段、関数毎に.pyを作って、それぞれに関数handlerを設けて、それをhandlerにしてる。

## SlackBotに接続する前に
SlackBotは、設定の途中でLambda等のAPIとの接続がうまくいっているか確かめるテストがある。なので、その段階に行くまでに、テストに対応できる挙動をLambdaに作っておく必要がある。

```yml:serverless.yml
service: chama-language-tweet-bot
frameworkVersion: '2'
provider:
  name: aws
  runtime: python3.8
  stage: dev
  region: us-east-1

functions:
  slackbot:
    handler: slackbot.handler
    timeout: 200
    events:
      - http:
          path: slackbot
          method: post
          cors: true
          integration: lambda
```

```py:slackbot.py
# coding: utf-8
import json
import logging

# ログ設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def handler(event, context):
    logging.info(json.dumps(event))

    # SlackのEvent APIの認証
    if "challenge" in event["body"]:
        return event["body"]["challenge"]

    return {
        'statusCode': 200,
        'body': 'ok'
    }
```

以上のようにして、```sls deploy```する。そして、エンドポイントをメモする。

## SlackBotの作成
では、SlackBotを使っていこう。
[Slack API](https://api.slack.com/) にアクセスして、「Create New App」から新しいSlack Appを作る。
![無題214_20210118105434.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/9c4cf9b8-47aa-1e27-2d79-aecba218aadd.png)
名前は適当に決めて、Development Slack Workspaceでインストール先のワークスペースを決める。~~この時、間違っても会社のワークスペースを選択しないように~~

Slack Appができたら、早速App Credentialsを探して、Verification Tokenをメモしておく。
![無題214_20210118110032.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/3b0c2081-f511-c536-bc79-deb17543379c.png)

次に、Slack APIを有効にする。今回はSlack Eventというものを使うので、Event Subscriptionsを開いて、Enable Eventsをonにする。
![無題214_20210118110113.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/53564a22-5546-3233-e020-39cd777be88f.png)

Enable Eventsをonにしたら、Request URLにLambdaのエンドポイントを入れる。そうすると、(ここまでのLambdaがちゃんとできていれば)Verifiedとなる。これでSlackとLambdaの接続が完了する。
![無題214_20210118110318.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/447e696e-309b-1501-20c5-fa0d482333c5.png)

次に、Event APIの反応の種類を設定する。Add Bot User Eventから「message.im」を選択する。これは、Botとの個人チャットに投稿がなされた時に反応するという設定だ。
![無題214_20210118113248.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/11986a51-a881-9b28-0d9e-92c480c1d1ae.png)

次に、Botに対して、メッセージを投稿する権限を付与する。OAuth & PermissionsからScopesを探して、そこの「Add an OAuth Scope」から「chat:write」を追加する。
![無題214_20210118113449.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/578480bc-0def-2133-dcaf-cdee1e4c6646.png)

これで、SlackBotの設定が終わったので、OAuth & Permissionsの「Install to Workspace」を押して、SlackBotをワークスペースにインストールする。
![無題214_20210118113329.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/4c03fec1-7cbe-c6aa-2bbb-26cddb9d43d5.png)

そして、その後に表示されるOAuth Tokenをメモする。
![無題214_20210118113735.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/753661d4-d441-51f6-2acd-080083487852.png)

さて、今度はSlackのアプリからBotの導入をしよう。
Slackのアプリの「アプリを追加する」を開いて、さっき作ったSlack Botの名前を検索し、導入する。そうしたら、Botのプロフィールを開き、「その他」から「メンバーIDをコピー」する。
![無題214_20210118115304.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/ecc95b07-737b-c8d8-5d15-4906c4fb5b8b.png)

これでSlack側の設定は完了！

## Python外部モジュールの導入
さて、Slack Botも出来たことだし、Lambdaで使う外部モジュールを導入する。
今回使うPython外部モジュールは以下の2つ。
・tweepy
・pykakashi
今回はこれらを、プラグインserverless-python-requirementsでぶち込んでいこうと思う。

*ところで、LambdaにPython外部モジュールを入れる方法というのは、serverless-python-requirementsを使う他に、Dockerを使って外部モジュールをAmazon Linux環境下でビルドして、圧縮して、それをLayerにする方法とかもある。でも、serverless-python-requirementsが一番楽。Dockerで作ったLayerだと、アップロードするのがめんどくさい(サイズが大きいとS3にレイヤーをアップロードしてそこにアクセスする必要が出てくるし)。さらに、Lambdaはデプロイパッケージのサイズがかなり限られてるから、場合によってはEFSを使う必要がでてくることもある。今回はめちゃめちゃ小さいのでserverless-python-requirementsで十分だが。*

さて、serverless-python-requirementsを導入する。

```
$ npm install --save serverless-python-requirements
```

そしたら、serverless.ymlに以下を追記する。

```yml:serverless.yml
plugins:
  - serverless-python-requirements
  
custom:
  pythonRequirements:
    dockerizePip: true
```

基本的にはpluginsのところだけで良いんだけど、今回はpykakashiが非PureなPythonモジュールだから、customのところも書いておく必要がある。**(更に言うと```sls deploy```する時にDockerを動かしておく必要がある。)**

次に、serverless.ymlとかと同階層にrequirements.txtを配置して、入れたい外部ライブラリを書く。

```txt:requirements.txt
tweepy
pykakasi
```

これで、```sls deploy```したら一緒に外部ライブラリも入ってくれる。

## キーの配置

さて、ここでキーをセットしていく。serverless.ymlにenvironmentを以下のように追記する。

```yml:serverless.yml
service: haachama-twitter-bot
frameworkVersion: '2'
provider:
  name: aws
  runtime: python3.8
  stage: dev
  region: us-east-1
  environment:
    SLACK_BOT_USER_ACCESS_TOKEN: ''
    SLACK_BOT_VERIFY_TOKEN: ''
    TWITTER_CONSUMER_KEY: ''
    TWITTER_CONSUMER_SECRET: ''
    TWITTER_ACCESS_TOKEN: ''
    TWITTER_ACCESS_TOKEN_SECRET: ''
    BOT_USER_ID: ''
```

それぞれの中身は、

SLACK_BOT_USER_ACCESS_TOKEN
　SlackBotをワークスペースにインストールした時に表示されたOAuth Token

SLACK_BOT_VERIFY_TOKEN
　SlackBotを作り始めて最初に出てきたVerification Token。

TWITTER_CONSUMER_KEY
TWITTER_CONSUMER_SECRET
TWITTER_ACCESS_TOKEN
TWITTER_ACCESS_TOKEN_SECRET
　TwitterDeveloperアカウントのキー。

BOT_USER_ID
　SlackのアプリでコピーしたBotの「メンバーID」。

## Lambda側の実装
### SlackBotの挙動

では、Lambdaの中身を書いていく。
まず、Botとの個人チャットでBotが適切に反応するようにする。

```py:slackbot.py
# coding: utf-8
import json
import os
import logging
import urllib.request

import tweepy
import pykakasi

# ログ設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def handler(event, context):
    logging.info(json.dumps(event))

    # SlackのEvent APIの認証
    if "challenge" in event["body"]:
        return event["body"]["challenge"]

    # tokenのチェック
    if not is_verify_token(event):
        return {'statusCode': 200, 'body': 'token error'}

    # 再送のチェック
    if "X-Slack-Retry-Num" in event["headers"]:
        return {'statusCode': 200, 'body': 'this request is retry'}

    # ボットへのメンションでない場合
    if not is_app_message(event):
        return {'statusCode': 200, 'body': 'this request is not message'}

    # 自分に反応しない
    if event["body"]["event"]["user"] == os.environ["BOT_USER_ID"]:
        return {'statusCode': 200, 'body': 'this request is not sent by user'}

    return {
        'statusCode': 200,
        'body': 'ok'
    }

def is_verify_token(event):
    token = event["body"]["token"]
    if token != os.environ["SLACK_BOT_VERIFY_TOKEN"]:
        return False
    return True

def is_app_message(event):
    return event["body"]["event"]["type"] == "message"
```

以上のコードでは、このようなことをしている。

- トークンの確認
- 再送の確認
 - Slack Event APIは3秒以内にstatusCode:200が返ってこないと勝手に4回までリクエストを再送してしまうから、それを防ぐ。今回のLambdaは処理に3秒もかからないだろうけど、結構これのせいで暴走して、(私が)ブチ切れたことがあるので書いた。ちなみに、"X-Slack-Retry-Num"は再送回数が格納されているプロパティで、初回の送信だとそもそも"X-Slack-Retry-Num"が存在しない。今回はそれで判別した。
- 投稿されたメッセージが「Botとの個人チャットに投稿されたもの」であることを判別する
- 暴走を防ぐ
 - 投稿されたメッセージがBot自身によるものである場合には反応しないようにする。もし、この部分を書かなかったら、Botが一度反応したら、自身の投稿に反応して返答することを繰り返してしまって、暴走してしまう。

*そうそう。余談だが、Slackのシステムはメッセージの特定に、メッセージ固有のIDではなくて、投稿がなされたチャンネルのIDとタイムスタンプを使っているらしい(Twitterは個々のツイートにIDが振られるのにね)。だから、Botがリプライするような実装をしたいときは投稿がなされたチャンネルのIDとタイムスタンプの2つが肝要になる。*

### Botによる返答
今回は、投稿がなされたら、一応確認とために返答もしたいから、これも書く。

```py:slackbot.py
def post_message_to_channel(channel, message):
    url = "https://slack.com/api/chat.postMessage"
    headers = {
        "Content-Type": "application/json; charset=UTF-8",
        "Authorization": "Bearer {0}".format(os.environ["SLACK_BOT_USER_ACCESS_TOKEN"])
    }
    data = {
        "token": os.environ["SLACK_BOT_VERIFY_TOKEN"],
        "channel": channel,
        "text": message,
    }

    req = urllib.request.Request(url, data=json.dumps(data).encode("utf-8"), method="POST", headers=headers)
    urllib.request.urlopen(req)
```

この関数に対して、handlerの中でチャンネルとテキストを以下のようにして渡せば、Botが「done!」と返答する。

```py:slackbot.py
    channel_id = event["body"]["event"]["channel"]
    post_message_to_channel(channel_id, "done!")
```

### 「と」の置換とツイート
次に、「ちゃま語」に翻訳して、結果をツイートする部分を書く。

```py:slackbot.py
def tweet(input_text):
    auth = tweepy.OAuthHandler(os.environ["TWITTER_CONSUMER_KEY"], os.environ["TWITTER_CONSUMER_SECRET"])
    auth.set_access_token(os.environ["TWITTER_ACCESS_TOKEN"], os.environ["TWITTER_ACCESS_TOKEN_SECRET"])
    api = tweepy.API(auth)

    kakasi = pykakasi.kakasi()
    kakasi.setMode('J', 'H')
    conv = kakasi.getConverter()
    input_text = conv.do(input_text)

    input_text = input_text.replace('と', 'ちゃま')
    input_text = input_text.replace('ト', 'ちゃま')
    input_text = input_text.replace('ﾄ', 'ちゃま')
    input_text = input_text.replace('ど', 'ぢゃま')
    input_text = input_text.replace('ド', 'ぢゃま')
    input_text = input_text.replace('ﾄﾞ', 'ぢゃま')

    api.update_status(status=input_text)
```

```py:slackbot.py
# handlerの中で呼び出し
    input_text = event["body"]["event"]["text"]
    tweet(input_text)
```

今回は、pykakashiで入力文を全て平仮名にして、その文の中に含まれる「と」を「ちゃま」、「ど」を「ぢゃま」に置換していく。そして、置換結果をツイートする。
*「ど」を「ぢゃま」に置換するのは、はあちゃま本人の提案ではなくマリン船長の配信で出てきたものらしいが、面白いので加えた。*

さて、これで完成だ。```sls deploy```をして、テストしてみよう。

![スクリーンショット 2021-01-17 15.32.55.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/48d04f9c-82dc-dd8a-cf8c-744ab94d6926.png)

![IMG_6424.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/97b767eb-184e-c8bb-35a3-16fd2e588ac5.png)

はい、できた。

![スクリーンショット 2021-01-17 15.32.55.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/0b4924c4-839d-3c09-7c79-bd5994b3210c.png)

![IMG_6426.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/1d52dfce-d69a-662c-64c7-ad40298d52fa.png)

漢字の変換も問題なさそうだね。

## コードの全容

```yml:serverless.yml
service: haachama-twitter-bot
frameworkVersion: '2'
provider:
  name: aws
  runtime: python3.8
  stage: dev
  region: us-east-1
  environment:
    SLACK_BOT_USER_ACCESS_TOKEN: ''
    SLACK_BOT_VERIFY_TOKEN: ''
    TWITTER_CONSUMER_KEY: ''
    TWITTER_CONSUMER_SECRET: ''
    TWITTER_ACCESS_TOKEN: ''
    TWITTER_ACCESS_TOKEN_SECRET: ''
    BOT_USER_ID: ''

plugins:
  - serverless-python-requirements

custom:
  pythonRequirements:
    dockerizePip: true

functions:
  slackbot:
    handler: slackbot.handler
    timeout: 200
    events:
      - http:
          path: slackbot
          method: post
          cors: true
          integration: lambda
```

```py:slackbot.py
# coding: utf-8
import json
import os
import logging
import urllib.request

import tweepy
import pykakasi

# ログ設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def handler(event, context):
    logging.info(json.dumps(event))

    # SlackのEvent APIの認証
    if "challenge" in event["body"]:
        return event["body"]["challenge"]

    # tokenのチェック
    if not is_verify_token(event):
        return {'statusCode': 200, 'body': 'token error'}

    # 再送のチェック
    if "X-Slack-Retry-Num" in event["headers"]:
        return {'statusCode': 200, 'body': 'this request is retry'}

    # ボットへのメンションでない場合
    if not is_app_message(event):
        return {'statusCode': 200, 'body': 'this request is not message'}

    # 自分に反応しない
    if event["body"]["event"]["user"] == os.environ["BOT_USER_ID"]:
        return {'statusCode': 200, 'body': 'this request is not sent by user'}

    input_text = event["body"]["event"]["text"]
    channel_id = event["body"]["event"]["channel"]

    tweet(input_text)
    post_message_to_channel(channel_id, "done!")

    return {
        'statusCode': 200,
        'body': 'ok'
    }

def post_message_to_channel(channel, message):
    url = "https://slack.com/api/chat.postMessage"
    headers = {
        "Content-Type": "application/json; charset=UTF-8",
        "Authorization": "Bearer {0}".format(os.environ["SLACK_BOT_USER_ACCESS_TOKEN"])
    }
    data = {
        "token": os.environ["SLACK_BOT_VERIFY_TOKEN"],
        "channel": channel,
        "text": message,
    }

    req = urllib.request.Request(url, data=json.dumps(data).encode("utf-8"), method="POST", headers=headers)
    urllib.request.urlopen(req)

def is_verify_token(event):
    token = event["body"]["token"]
    if token != os.environ["SLACK_BOT_VERIFY_TOKEN"]:
        return False
    return True

def is_app_message(event):
    return event["body"]["event"]["type"] == "message"

def tweet(input_text):
    auth = tweepy.OAuthHandler(os.environ["TWITTER_CONSUMER_KEY"], os.environ["TWITTER_CONSUMER_SECRET"])
    auth.set_access_token(os.environ["TWITTER_ACCESS_TOKEN"], os.environ["TWITTER_ACCESS_TOKEN_SECRET"])
    api = tweepy.API(auth)

    kakasi = pykakasi.kakasi()
    kakasi.setMode('J', 'H')
    conv = kakasi.getConverter()
    input_text = conv.do(input_text)

    input_text = input_text.replace('と', 'ちゃま')
    input_text = input_text.replace('ト', 'ちゃま')
    input_text = input_text.replace('ﾄ', 'ちゃま')
    input_text = input_text.replace('ど', 'ぢゃま')
    input_text = input_text.replace('ド', 'ぢゃま')
    input_text = input_text.replace('ﾄﾞ', 'ぢゃま')

    api.update_status(status=input_text)
```

```txt:requirements.txt
tweepy
pykakasi
```

## (蛇足)Botの体裁を整える
テストしてみて思ったんだけど、やっぱり、Botのアイコンが初期のままでは、なんかパッとしないよね。せっかくだから描こうか。...はい、描きました。
~~最後にアイコン描いたのが工程の中で一番時間かかってるかもしれん。~~
Slack Botのアイコン設定は、Slack AppのBasic InformationのDisplay Informationからできて、こんな感じでやる。
![スクリーンショット 2021-01-18 12.08.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/2607877a-ae24-e43e-6dd7-3c44983cfc75.png)

変更したら「Save Changes」を押すのを忘れずに。

はい、良い感じになりました。やったね。この方が、「ちゃんとできてる感」がある。
![IMG_6470.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334730/4d72cc44-346f-5e1c-e20c-f948112ccc04.jpeg)


# (蛇足)平仮名への変換について
ところで、入力文を平仮名に変換するところは、pykakashiではなくMeCabと適当な辞書(UniDicとかneologdとか)の方が正確に出力できるんだろう。だけど、pykakashiの方が圧倒的にLambdaに組み込みやすい~~のと、今回はネタなのであまり精度は要求されてない~~ことから、pykakashiで済ませた。MeCabと辞書をLambdaに組み込む場合、MeCabと辞書をAmazon Linux環境下でビルドする必要があるので、EC2インスタンス(t2.medium以上のもの。t2.microだとメモリ不足だった)でビルドして、EFSにマウントする必要がある。LambdaでMeCab(& UniDic)を使う楽な方法はないものかな...