息子の可愛さを普及するために、AWS + LINEでBotを作った話

2020年7月、我が家に長男が誕生。
もう天使。かわいい。CMのオファー来るんじゃないのか？（親バカ）

親族・友人に息子を会わせて、可愛さを自慢やりたかったが、このご時世それも叶わず。。。
我が息子の可愛さを普及したい。どうしよう。

そうだ。我が息子の可愛さを普及するLINE Botを作ろう。

# 1. デモ
最初にご紹介。（かわいい）
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/21dbaea6-a7d8-fc69-5869-b1a5cda7fe70.gif)

# 2. 全体構成
![Design.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/f004c5e4-e8ef-b69d-a4a6-67f82dac8bb0.png)

2-1. LINEBotからWebHookでAPIGateway→Lambda実行
2-2. メッセージを解析して、返信用のメッセージと画像を取得
2-3. LINEに返信

とシンプルなもの。
GASとかで構築した方が安い。というのは秘密。

# 3. LINE①
まずはLINEBot MessagingAPIの設定。
下記サイトを参考に設定。
[1時間でLINE BOTを作るハンズオン (資料+レポート) in Node学園祭2017 #nodefest](https://qiita.com/n0bisuke/items/ceaa09ef8898bee8369d)

Webhook URLはAPI構築後に設定。

# 4. AWS
Lambda + DynamoDBはCloudFormationで、APIGateway + S3は手動で構築。

## 4-1. CloudFormation

```template.yml
AWSTemplateFormatVersion: 2010-09-09
Resources:
  HaruBotFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code: ./release/app.zip
      FunctionName: HaruBotFunction
      Handler: index.handler
      Runtime: nodejs12.x
      Role:
        Fn::GetAtt:
          - "HaruBotRole"
          - "Arn"
      MemorySize: 128
      Timeout: 30
      Environment:
        Variables:
          TZ: Asia/Tokyo
          ACCESSTOKEN: <LINE ACCESSTOKEN>
          CHANNELSECRET: <LINE CHANNELSECRET>

  ReplyMapping:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: ReplyMapping
      AttributeDefinitions:
        - AttributeName: type
          AttributeType: S
      KeySchema:
        - AttributeName: type
          KeyType: HASH
      ProvisionedThroughput:
        ReadCapacityUnits: 3
        WriteCapacityUnits: 3

  HaruBotRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - "edgelambda.amazonaws.com"
                - "lambda.amazonaws.com"
                - "dynamodb.amazonaws.com"
                - "cloudwatch.amazonaws.com"
            Action:
              - sts:AssumeRole
```
- ACCESSTOKEN, CHANNELSECRETはLINE Bot設定時に発行した値を設定。
- ReadCapacityUnits, WriteCapacityUnitsは適当に。一応複数人に公開することを考慮して3。
- Roleは最低限の権限のみ付与。

## 4-2. DynamoDB

### 4-2-1. テーブルデザイン

|Key|型|説明|
|:-----------------|:-----------------|:-----------------|
|type|String|PK|
|img|List<String>|画像URLのリスト、今回はS3上に静的WebホスティングしたURLを設定|
|msg|List<String>|返信メッセージのリスト|

### 4-2-2. サンプル

```.json
{
  "img": [
    "https://hogehoge.com/oyasumi01.jpewg",
    "https://hogehoge.com/oyasumi02.jpewg"
  ],
  "msg": [
    "おやすみなちゃい",
    "Zzzz..."
  ],
  "type": "おやすみ"
}
```



## 4-3. Lambda

### 4-3-1. ソース全文

```index.js
'use strict'

const line = require('@line/bot-sdk')
const client = new line.Client({ channelAccessToken: process.env.ACCESSTOKEN })
const crypto = require('crypto')
const AWS = require('aws-sdk')
const dynamodb = new AWS.DynamoDB.DocumentClient({
  region: 'ap-northeast-1'
})

exports.handler = async (event) => {
  return new Promise(async (resolve, reject) => {
    const signature = crypto.createHmac('sha256', process.env.CHANNELSECRET).update(event.body).digest('base64')
    const checkHeader = (event.headers || {})['X-Line-Signature']

    if (signature !== checkHeader) {
      reject('Authentication error')
    }

    const body = JSON.parse(event.body)
    const events = body.events[0]
    const message = events.message.text

    const param = {
      TableName: 'ReplyMapping',
      Key: {
        type: message
      }
    }
    const result = await dynamodb.get(param).promise()
    let msg, img
    console.log(result.Item)
    if (result.Item) {
      const getRandomList = (list) => list[Math.floor(Math.random() * list.length)]
      msg = getRandomList(result.Item.msg)
      img = getRandomList(result.Item.img)
    } else {
      msg = `${message}ってなんでちゅか？`
      img = ''
    }

    const replyText = {
      type: 'text',
      text: msg
    }
    const replyImage = {
      type: 'image',
      originalContentUrl: img,
      previewImageUrl: img
    }

    client.replyMessage(events.replyToken, replyText)
      .then((r) => {
        return client.pushMessage(events.source.userId, replyImage)
      })
      .then((r) => {
        resolve({
          statusCode: 200,
          headers: { 'X-Line-Status': 'OK' },
          body: '{"result":"completed"}'
        })
      }).catch(reject)
  })
}

```

### 4-3-2. 認証処理

```.js
    const signature = crypto.createHmac('sha256', process.env.CHANNELSECRET).update(event.body).digest('base64')
    const checkHeader = (event.headers || {})['X-Line-Signature']

    if (signature !== checkHeader) {
      reject('Authentication error')
    }
```

LINEからハッシュ化されている値と突合して、正しいリクエストかどうかハンドリング。

### 4-3-3. 返信用のメッセージ、画像を取得

```.js
    const body = JSON.parse(event.body)
    const events = body.events[0]
    const message = events.message.text

    const param = {
      TableName: 'ReplyMapping',
      Key: {
        type: message
      }
    }
    const result = await dynamodb.get(param).promise()
    let msg, img
    if (result.Item) {
      const getRandomList = (list) => list[Math.floor(Math.random() * list.length)]
      msg = getRandomList(result.Item.msg)
      img = getRandomList(result.Item.img)
    } else {
      msg = `${message}ってなんでちゅか？`
      img = ''
    }
```

送信されたメッセージを元に、DynamoDBからレコード取得。
※ ユーザーからの送信テキストををKeyに完全一致でレコードを取得しているため、良さげなワードから良さげに引っ張ってくることはできない。
例えば、「かわいい」のみレコードを登録していた場合、「かわいいね」とメッセージが送られてきても、良さげな返信はされない。
形態素解析して、良さげな言葉を引っ張ってきたかったが、時間が足りず。。（お宮参りに間に合わせたかったんだもん。）

レコードが存在する場合は、レコード内のimg, msgからランダムに値を取得。
存在しない場合はデフォルトを返却。

### 4-3-4. 返信

```.js
    const replyText = {
      type: 'text',
      text: msg
    }
    const replyImage = {
      type: 'image',
      originalContentUrl: img,
      previewImageUrl: img
    }

    client.replyMessage(events.replyToken, replyText)
      .then((r) => {
        return client.pushMessage(events.source.userId, replyImage)
      })
      .then((r) => {
        resolve({
          statusCode: 200,
          headers: { 'X-Line-Status': 'OK' },
          body: '{"result":"completed"}'
        })
      }).catch(reject)
 ```
詳しいことは[ドキュメント](https://developers.line.biz/ja/docs/messaging-api/sending-messages/)を参考に。

## 4-4. APIGateway
RestAPIで作成。
公開するAPIではないので、特にカスタムドメイン名の設定はしない。

### 4-4-1. APIGateway > API作成押下
![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/9fdb9014-a0e0-ab1e-155e-b6a24d82cd88.png)

### 4-4-2. REST API > 構築
![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/b4d28389-9a5f-c3b0-84cb-081c8a5f3a82.png)

### 4-4-3. API名入力 > APIの作成
任意の文字列を入力する。
![03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/2159d927-40e8-c304-4d15-abfd4cddf076.png)

### 4-4-4. メソッドの作成 > post選択
![04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/52445f3d-e1d4-66de-ae8b-e4946e20088e.png)
![05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/3e3a288c-e1f8-eee7-cfb9-0d240bd20021.png)

### 4-4-5. Lambda関数選択 > 保存
Lambda関数はCloudFormationmで構築したLambda関数を定義。
今回は `HaruBotFunction`
![06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/d36c70cc-9577-70ed-59c7-2b1697eef2e0.png)

権限付与ダイアログは脳死でOK
![07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/78e6b23f-5da8-a8a4-bb10-1e53d4068947.png)

### 4-4-6. メソッドリクエスト > HTTPリクエストヘッダー追加
![08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/5954ab53-6712-9887-c7ed-421ae0d33d5b.png)

設定する値。

|名前|必須|キャッシュ|
|:-----------------|:-----------------|:-----------------|
|X-Line-Signature|T|F|

![09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/7b627137-dcfb-25c9-9a62-2c64c1bf9ecd.png)

### 4-4-7. APIのデプロイ > URLコピー
![10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/73b28d7b-71ce-129c-e0e7-c035f8c80b5f.png)
ステージ名は任意の文字列を入力。
![11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/466b4f06-607a-27fd-b990-163b8f820aa1.png)
枠内のURLを控える。
![12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/5d9299d3-1786-dd54-6323-f14a6889748b.png)

# 5. LINE②

4-4-7. でコピーしたURLを、LINE Developers > Messaging API設定 > Webhook設定 のWebhook URLに登録。
![スクリーンショット 2020-08-12 22.02.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/300430/04c3571d-0f28-8f14-87dc-30e68b294fcc.png)

# 6. 完成

これで完成。
DynamoDBに会話用のレコードを登録し、CDNに画像をアップロードすればデモのように動かせることができる。

# 7. まとめ

## 7-1. 残念なロジック
今回は、息子に直接会えない親族や、友人に少しでも息子に触れ合ってもらえるようにBotを構築。
お宮参り後に公開したくてちょっと無理やりな構成になってしまったのは残念。
特に、形態素解析して、ワードをピックアップするロジックを組みたかったけど、うまくいかなかったため、残念なロジックに・・・
ここは改善したい。

ゆくゆくは、メンテナンス不要で、いい感じに更新されていくような構築ができたら完璧。

## 7-2. CDNをS3にする必要は..
家族アルバムアプリの[みてね](https://mitene.us/)っていい感じのAPIないかなぁ？
日時指定すると、いい感じの画像を返却してくれるAPIが欲しい。笑
もしあれば課金しますわ。

というか、Instagramでもいいかなぁ。（怒られる恐れあり？）

# 8. 追記
続編 [息子の可愛さを普及するために、AWS + LINEでBotを作った話〜形態素解析導入編〜
](https://qiita.com/yamachita0109/items/d4ac1faf9b67f2e3450a)