CDKで「買い物リスト」LINE botをつくる

# はじめに
久しぶりにLINE Botをつくってみました。今回はAWSでバックエンドの環境を構築してみましたが、[AWS CDK](https://aws.amazon.com/jp/cdk/)を使ったのでAWSのコンソールを全く触らずにできました。

# botの内容

こんな感じの「買い物リストbot」をつくりました。ここの[githubで公開しています](https://github.com/ufoo68/shopping-list-bot)。

![clear.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209689/b88efad9-1572-b4cf-122a-2866f4295ba6.png)

このbotができることは、

1. 買いたいものをどんどんリストに追加する
2. 「リスト確認」で現在のリストを確認できる
3. 「リストをクリア」でリストの中身を空にする

という単純なものです。このbotを作るにはデータを保管するストレージが必要になります。今回は[DynamoDB](https://aws.amazon.com/jp/dynamodb/)を使いました。

# 開発準備
開発にあたって、[ここのページ](https://dev.classmethod.jp/server-side/serverless/aws-cdk-line-sample/)を参考にさせていただきました。
あと、以下の自分用につくったnpmパッケージを使いました。

- [line-message-builder](https://www.npmjs.com/package/line-message-builder)
- [cdk-lambda-api](https://www.npmjs.com/package/cdk-lambda-api)

# 開発
## CDKの構成
AWSの構成はこんな感じです（cdkだとコードだけでアーキテクチャを説明できるので楽ですね）。

```typescript:/lib/cdk-line-bot-stack.ts
import * as cdk from '@aws-cdk/core'
import { LambdaApi } from 'cdk-lambda-api'
import * as dynamodb from '@aws-cdk/aws-dynamodb'

export class CdkLineBotStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const table = new dynamodb.Table(this, 'Table', {
      partitionKey: { name: 'userId', type: dynamodb.AttributeType.STRING }
    })

    const lambdaApi = new LambdaApi(this, 'LineBot', {
      lambdaPath: 'Linebot',
      environment: {
        ACCESS_TOKEN: process.env.ACCESS_TOKEN!,
        CHANNEL_SECRET: process.env.CHANNEL_SECRET!,
        TABLE_NAME: table.tableName,
      }
    })

    table.grantFullAccess(lambdaApi.handler)
  }
}
```

`LambdaApi`というライブラリを呼ぶと`Lambda`と`API Gateway`を一々呼ばなくても簡単にAPIで呼び出せるLambdaが作成できます。
また、`ACCESS_TOKEN`と`CHANNEL_SECRET`は`.env`ファイルで用意します。このとき、`cdk.json`を以下のように書き換えます。

```json:cdk.json
{
  "app": "node -r dotenv/config -r ts-node/register bin/cdk-line-bot.ts"
}
```

あと、`dotenv`をインストールしておきます。

```bash
yarn add dotenv
```

## lambdaの実装

これでAWSの構成は定義できたので、lambdaを書いていけばいつもどおりにLINE botが作成できます（当然、LINE Developersでの設定は必要になりますが）。まずは対話モデルを作成してみました。

```typescript:/linebot/conversationModel.ts
export enum MessageType {
    Confirm,
    Clear,
    Add,
}

interface ReplyModel {
    message: string,
    type: MessageType,
}

export const conversation = (message: string): ReplyModel => {
    switch (message) {
        case 'リスト確認':
            return { message: '現在の買い物リストです。', type: MessageType.Confirm }
        case 'リストをクリア':
            return { message: 'リストをクリアしました', type: MessageType.Clear }
        default:
            return { message: `${message}をリストに追加します。`, type: MessageType.Add }
    }
}
```

拾い上げるワードは「リスト確認」と「リストをクリア」だけ拾い上げてあとは「買い物リストに追加したいもの」とみなします。
あとは、DynamoDBの操作をするための`dbHandler`を実装します。

```typescript:/linebot/dbHandler.ts
import * as AWS from 'aws-sdk'
import { MessageType } from './conversationModel'

const dynamodb = new AWS.DynamoDB()

async function addList(message: string, userId: string): Promise<string[]> {
  const { Item } = await dynamodb.getItem({
    TableName: process.env.TABLE_NAME!,
    Key: {
      userId: {
        S: userId
      }
    },
  }).promise()
  const shoppingList: AWS.DynamoDB.AttributeValue[] = [{ S: message }]
  if (Item) {
    Item.shoppingList.L?.map(value=>shoppingList.push(value))
  }
  await dynamodb.putItem({
    TableName: process.env.TABLE_NAME!,
    Item: {
      userId: {
        S: userId
      },
      shoppingList: {
        L: shoppingList
      }
    }
  }).promise()
  return []
}

async function confirmList(userId: string): Promise<string[]> {
  const { Item } = await dynamodb.getItem({
    TableName: process.env.TABLE_NAME!,
    Key: {
      userId: {
        S: userId
      }
    },
  }).promise()
  const shoppingList: string[] = []
  if (Item) {
    Item.shoppingList.L?.map(value=>shoppingList.push(value.S!))
  }
  return shoppingList
}

async function clearList(userId: string): Promise<string[]> {
  await dynamodb.deleteItem({
    TableName: process.env.TABLE_NAME!,
    Key: {
      userId: {
        S: userId
      }
    },
  }).promise()
  return[]
}

export async function dbHandler(messageType: MessageType, message: string, userId: string): Promise<string[]> {
  switch(messageType) {
    case MessageType.Add:
      return await addList(message, userId)
    case MessageType.Confirm:
      return await confirmList(userId)
    case MessageType.Clear:
      return await clearList(userId)
  }
}
```

ここらへんの実装自分でもいまいちだと思っているので指摘あればGitHubの方にPRください！
最後にこれらを呼び出す`index.handler`を実装します。

```typescript:/linebot/index.ts
import * as Lambda from 'aws-lambda'
import * as Line from '@line/bot-sdk'
import * as Types from '@line/bot-sdk/lib/types'
import { buildReplyText } from 'line-message-builder'
import { conversation } from './conversationModel'
import { dbHandler } from './dbHandler'

const channelAccessToken = process.env.ACCESS_TOKEN!
const channelSecret = process.env.CHANNEL_SECRET!

const config: Line.ClientConfig = {
    channelAccessToken,
    channelSecret,
}
const client = new Line.Client(config)

async function eventHandler(event: Types.MessageEvent): Promise<any> {
    if (event.type !== 'message' || event.message.type !== 'text' || !event.source.userId) {
        return null
    }
    const message = conversation(event.message.text)

    const replyText = [message.message]

    const shoppingList = await dbHandler(message.type, event.message.text, event.source.userId)
    if (shoppingList.length > 0) {
        shoppingList.map(value => replyText.push(value))
    }
    return client.replyMessage(event.replyToken, buildReplyText(replyText))
}

export const handler: Lambda.APIGatewayProxyHandler = async (proxyEevent: Lambda.APIGatewayEvent, _context) => {
    console.log(JSON.stringify(proxyEevent))

    const signature = proxyEevent.headers['X-Line-Signature']
    if (!Line.validateSignature(proxyEevent.body!, channelSecret, signature)) {
        throw new Line.SignatureValidationFailed('signature validation failed', signature)
    }

    const body: Line.WebhookRequestBody = JSON.parse(proxyEevent.body!)
    await Promise
        .all(body.events.map(async event => eventHandler(event as Types.MessageEvent)))
        .catch(err => {
            console.error(err.Message)
            return {
                statusCode: 500,
                body: 'Error'
            }
        })
    return {
        statusCode: 200,
        body: 'OK'
    }
}
```

さきほど`LambdaApi`で渡した環境変数を`process.env.hogehoge`で呼び出すことができます。

# さいごに
2,3時間程度で作った（設計もまともにしていない）botなのでシステムとしては荒いですが、今回でcdkで簡単にLINE botが作れることがわかりました。AWS CDKを使う利点は、API GatewayとLambda以外のAWSリソースを使いたいときに簡単に呼び出せることだと思います。