LINE Botの自動返信メソッドをLambdaで作る

## 概要
- AWS LambdaとDynamoDBを使ってLINE Botを作ったので、その備忘録の1つです。
- この記事では、LINE Botの自動返信メソッドをLambdaで作る手順を説明します。

## 環境
macOS: 13.5.1
Homebrew: 4.1.4
Node.js: v18.14.1
aws-cli: aws-cli/2.13.7 Python/3.11.4 Darwin/22.6.0 source/arm64 prompt/off

## 前提条件
- [AWS CLI](https://aws.amazon.com/jp/cli/)のダウンロード・インストール済み
- CLI操作用のIAMユーザーを作成済み。
    - IAMユーザーの作成方法は [以前書いた別の記事(CLI&コンソール作業用のAWS IAMユーザーを作成する)](https://qiita.com/zono345/items/d1934a812c88b655364a) を参照のこと。

## サマリー(全体の流れ)
1. DynamoDBの作成
1. Lambda用のIAMロールの作成
1. コンソールでLambdaの作成
1. Lambda環境変数の設定
1. Lambdaのコードの実装・デプロイ
1. API Gatewayの設定

## DynamoDBの作成
- AWSへログインし、DynamoDB -> テーブル -> テーブルの作成　をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/90cc138b-3c0a-d03f-74c0-c890b6f3283d.png)

- テーブル名を入力
    - 今回はLINEのユーザー情報を格納するテーブルなので `linebot_user_sample` とします。
- パーティションキーを入力
    - LINE Botを友達登録しているユーザーのユーザーIDを格納するので `user_id` とします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/97e0c4b0-cca4-725f-790c-d490f33458fa.png)

- テーブル設定は デフォルト設定　とします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/8c1a1a62-3879-875a-5da7-08e4ff57d928.png)

- テーブルの作成　をクリックし、テーブル作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/a73d6ffd-fd63-3bc1-85d8-98671c5feab5.png)

- 作成されたら、テーブル名をクリックし、ARNをメモっておきます。
    - あとで、IAMロールの作成時に使います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/2fb4cc41-6e66-ecda-2037-04062c02947c.png)

## Lambda用のIAMロールの作成
- まず最初に、先ほど作成したDynamoDBのテーブルに対する読み書き権限のポリシーを作成します。
- IAM -> ポリシー -> 『ポリシーを作成』をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/9cb5b735-ba40-7f98-34ac-29575a913652.png)

- `JSON` をクリックし、下記のjsonを入力し `次へ` をクリック。
    - `"Resource"` は、先ほど作成したテーブルのarnや、自分のアカウント番号を入力してください。
```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:BatchGetItem",
                "dynamodb:GetItem",
                "dynamodb:Query",
                "dynamodb:Scan",
                "dynamodb:BatchWriteItem",
                "dynamodb:PutItem",
                "dynamodb:UpdateItem"
            ],
            "Resource": "arn:aws:dynamodb:ap-northeast-1:999999999999:table/linebot_user_sample"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:ap-northeast-1:999999999999:*"
        },
        {
            "Effect": "Allow",
            "Action": "logs:CreateLogGroup",
            "Resource": "*"
        }
    ]
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/44e74bd5-df5c-b51a-906b-a8cccbfc19b7.png)

- 任意のポリシー名を入力します。今回は `MyPolicy_dynamodb_read_write_linebot_sample` とします。
    - 説明は空欄でも構いません。
- `ポリシーの作成` をクリックして、作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/ea9f5ac3-e190-de0b-0224-8888427ee6b7.png)

- 一覧を見ると、無事、ポリシーが作成されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/51a31ddc-701e-3a8e-acbc-62a1625807c9.png)

- 次に、LambdaにアタッチするIAMロールの作成をします。
- IAM -> ロール -> `ロールを作成` をクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/fb63790a-8858-dce7-8f35-1144b2ece110.png)

- 信頼されたエンティティタイプ -> `AWS のサービス`, ユースケース -> `Lambda` を選択し `次へ` をクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/8b79aedf-3921-eb93-5a76-2945a985dd69.png)

- 先ほど作成したDynamoDBへの書き込みを許可するロール `MyPolicy_dynamodb_read_write_linebot_sample` のレ点チェックをつけます。
    - これにより、特定のテーブルに対してのみ、読み書き権限が付与されます。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/4043b972-6b79-d308-d7f9-5bc3a16a86bb.png)

- 次に `AWSLambdaBasicExecutionRole` のレ点チェックをつけ、`次へ`をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/e35148ab-a3d9-075c-f3ee-ff57e38e596a.png)

- 任意のロール名を入力し、`ロールを作成`をクリックします。
    - 今回のロール名は `MyLambdaRole_linebot_sample_dynamodb_read_write` とします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/f94d3091-6d2d-be5c-8285-05dc31a1f04d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/9f55b8a0-1a92-1f4f-10b7-e9836fc4b76d.png)

- これでIAMロールの作成は完了です。

## コンソールでLambdaの作成
- Lambda -> 関数 -> `関数の作成` をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/b11050e4-6d44-6f2c-3e66-5b7c8b48d1ea.png)

- 以下の設定にします。
    - 分類
        - 一から作成
    - 関数名
        - なんでもOK(今回は `linebot_auto_reply_sample` )
    - ランタイム
        - Node.js 18.x
    - アーキテクチャ
        - arm64
    - 実行ロール
        - 既存のロールを使用する
    - 既存のロール
        -  先ほど作成したロール(`MyLambdaRole_linebot_sample_dynamodb_read_write`)
-  `関数の作成`をクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/e8558769-b8a1-1519-5cf6-354ee419d27b.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/eebc66b4-8092-131a-659d-03c23e00dec4.png)

- Lambda関数が作成されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/815dfeaf-a0db-eb7d-53e0-0685e6b86b95.png)

## Lambda環境変数の設定
- シークレットなどを環境変数に登録します。
- 設定 -> 環境変数 -> `編集`をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/aa40ac2b-23e2-752f-7b6f-fa014ea2f4f1.png)

- 3つの環境変数を登録します。
    - ACCESS_TOKEN : LINE Botのチャネルアクセストークン
    - DYNAMODB_TABLE_USER : 先ほど作ったDynamoDBのテーブル名
    - LINE_CHANNEL_SECRET : LINE Botのチャネルシークレット
- 環境変数の追加 -> コピペ -> `保存`をクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/0c3abfb0-6bf5-fa53-8ba6-71f4fc2f8f3d.png)

- これで環境変数の登録完了です。

## Lambdaのコードの実装・デプロイ
- VSCodeを起動し、作業ディレクトリへ移動。

```bash
cd hogetaro/my_lambda_project
```

- npm初期化を実行
    - 途中の選択肢は、全てデフォルト設定でOKです。(Enter連打でOK)
```bash:my_lambda_project/
npm init
```

- LINE Bot SDKをインストール
    - [LINE公式ドキュメント](https://developers.line.biz/ja/docs/messaging-api/line-bot-sdk/)
    - [GitHub](https://github.com/line/line-bot-sdk-nodejs)

```bash:my_lambda_project/
npm install @line/bot-sdk
```

- DynamoDBへの読み書きを実行するためにAWS SDKをインストール。
    - [npm公式ドキュメント](https://www.npmjs.com/package/aws-sdk)
    - [AWS公式ドキュメント](https://docs.aws.amazon.com/ja_jp/sdk-for-javascript/v2/developer-guide/installing-jssdk.html)

```bash:my_lambda_project/
npm install aws-sdk
```


- 自動zip化＆自動デプロイするために、package.jsonを編集します。
- `"scripts"` に以下を追記します。
    - arnの箇所には、先ほど作成したLambda関数のarnを記載してください。

```json
"deploy": "aws lambda update-function-code --function-name arn:aws:lambda:ap-northeast-1:999999999999:function:linebot_auto_reply_sample --zip-file fileb://Lambda-Deployment.zip",
"predeploy": "zip -r Lambda-Deployment.zip * -x *.zip *.json *.log"
```
- 解説
    - "deproy"
        - `Lambda-Deployment.zip` というファイルを `arn:aws:lambda:ap-northeast-1:999999999999:function:linebot_auto_reply_sample` の関数へアップロードする。
    - "predeploy"
        - "deploy"が実行される前に `Lambda-Deployment.zip` というzipファイルを作成する。ただし、.zip, .json, .log の拡張子ファイルはzip化しない。(=index.jsとnode_modulesがzip化されます)

- 修正後のコードは以下の通りです。

```json:my_lambda_project/package.json
{
  "name": "my_lambda_project",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "deploy": "aws lambda update-function-code --function-name arn:aws:lambda:ap-northeast-1:999999999999:function:linebot_auto_reply_sample --zip-file fileb://Lambda-Deployment.zip",
    "predeploy": "zip -r Lambda-Deployment.zip * -x *.zip *.json *.log"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@line/bot-sdk": "^7.5.2",
    "aws-sdk": "^2.1443.0"
  }
}
```

- 次に自動でデプロイができるか確認してみます。
- index.jsを作成します。
```javascript:my_lambda_project/index.js
// deployテスト

export const handler = async (event) => {
    // TODO implement
    const response = {
      statusCode: 200,
      body: JSON.stringify('Hello from Lambda!'),
    };
    return response;
  };

```

- AWS CLI用のIAMユーザーを作業ディレクトリで設定します。
    - [関連記事](https....)も参照のこと。
    - `dev_cli` は適切なユーザー名に修正のこと。

```bash:my_lambda_project/
export AWS_DEFAULT_PROFILE=dev_cli
```
- デプロイコマンドを実行
    - zip化も行われます。

```bash:my_lambda_project/
npm run deploy
```
- デプロイできているかは、Lambdaのコンソールを確認すると分かります。
- `最終更新`の時間や`コードソース`のコメントで、デプロイできていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/1ee35d2a-d36d-0da5-4864-6093701e79d0.png)

- LINE Bot自動返信機能を実装するために、index.jsを修正します。

```javascript:my_lambda_project/index.js
const line = require('@line/bot-sdk');
const AWS = require('aws-sdk');

/*
* セキュリティ関係の処理
*/
// LINEからのリクエストかどうかをチェックする
const checkRequestFromLine = (event) => {
    const signature = event.headers["x-line-signature"];
    const body = event.body;
    const channelSecret = process.env.LINE_CHANNEL_SECRET;

    // 署名とbodyをログに出力
    console.log("signature -> ", signature);
    console.log("body -> ", JSON.stringify(body));

    // 署名検証
    const isValid = line.validateSignature(body, channelSecret, signature);
    return isValid;
};


/*
* DynamoDB関係の処理
*/
// ユーザーの投稿テキストを更新する
const updateUserText = async(userId, userText) => {
    const docClient = new AWS.DynamoDB.DocumentClient();

    // 更新するパラメータを設定
    const params = {
        TableName: process.env.DYNAMODB_TABLE_USER,
        Key: {
            user_id: userId
        },
        UpdateExpression: "set user_text = :user_text",
        ExpressionAttributeValues: {
            ":user_text": userText,
        }
    };

    // DBへ書き込み
    docClient.update(params, function(err, data) {
        if(err) {
            console.log("updateUserText() -> error -> ", err);
        } else {
            console.log("updateUserText()成功 -> data -> ", data);
        }
    });
};


/*
* LINE WebHookからのイベント処理メソッド
*/

// textMessageイベントの処理
const textMessageEventReply = async(event) => {
    // 送信先のLINE Bot
    const client = new line.Client({
        channelAccessToken: process.env.ACCESS_TOKEN
    });

    // リプライトークン
    const replyToken = event.replyToken;
    // リプライコンテンツ
    let replyContents = null;

    // テキストメッセージの場合
    if (event.message.type === 'text') {
        // ユーザーの投稿テキスト
        const userText = event.message.text;
        console.log("userText: "+userText);
        replyContents = {
            'type': 'text',
            'text': "あなたの入力した言葉は" + userText + "ですね"
        };

        // ユーザーの投稿テキストを更新する
        const userId = event.source.userId;
        await updateUserText(userId, userText);

    } else {
        const replyText = "ごめんなさい！その種類の投稿には返信できないんです";
        replyContents = {
            'type': 'text',
            'text': replyText
        };
    }

    // クライアントへメッセージ送信
    try {
        await client.replyMessage(replyToken, replyContents);
    } catch(err) {
        console.log("error -> ", err);
    }
};


// postBackイベントの処理
const postBackEventReply = async(event) => {
    // 送信先のLINE Bot
    const client = new line.Client({
        channelAccessToken: process.env.ACCESS_TOKEN
    });

    // postBackイベント内容
    const postBackEventData = event.postback.data;
    console.log("postBackEventData: "+postBackEventData);

    // 返信用トークン
    const replyToken = event.replyToken;

    // リッチメニュー切り替えの場合は返信しない
    if(postBackEventData.includes("richmenu-changed-to")) {
        console.log("リッチメニュー切り替えイベント")
        return
    }

    const replyContents = {
        'type': 'text',
        'text': postBackEventData+" のボタンをタップしましたね！"
    };

    // クライアントへ返信
    try {
        await client.replyMessage(replyToken, replyContents);
    } catch(err) {
        console.log("error -> ", err);
    }
};


// followイベントの処理
const followEventReply = async (event) => {
    // followerのuserId
    const userId = event.source.userId;
    console.log("userId " + userId + " が友達登録しました");

    const docClient = new AWS.DynamoDB.DocumentClient();

    // is_followをtrueに更新する
    const params = {
        TableName: process.env.DYNAMODB_TABLE_USER,
        Key: {
            user_id: userId
        },
        UpdateExpression: "set is_follow = :is_follow",
        ExpressionAttributeValues: {
            ":is_follow": true
        }
    };

    try {
        // DBへ書き込み
        const data = await new Promise((resolve, reject) => {
            docClient.update(params, function(err, responseData) {
                if (err) {
                    reject(err);
                } else {
                    resolve(responseData);
                }
            });
        });
        console.log("followEventReply()成功 -> data -> ", data);
    } catch (err) {
        console.log("followEventReply() -> error -> ", err);
    }
}


// unfollowイベントの処理
const unfollowEventReply = async (event) => {
    // unfollowのuserId
    const userId = event.source.userId;
    console.log("userId " + userId + " が友達登録解除しました");

    const docClient = new AWS.DynamoDB.DocumentClient();

    // is_followをfalseに更新する
    const params = {
        TableName: process.env.DYNAMODB_TABLE_USER,
        Key: {
            user_id: userId
        },
        UpdateExpression: "set is_follow = :is_follow",
        ExpressionAttributeValues: {
            ":is_follow": false
        }
    };

    try {
        // DBへ書き込み
        const data = await new Promise((resolve, reject) => {
            docClient.update(params, function(err, responseData) {
                if (err) {
                    reject(err);
                } else {
                    resolve(responseData);
                }
            });
        });
        console.log("unfollowEventReply()成功 -> data -> ", data);
    } catch (err) {
        console.log("unfollowEventReply() -> error -> ", err);
    }
}




/*
* LINEからのリクエストを処理する
*/
exports.handler = async (event) => {
    console.log('event -> ', event);

    // LINEの疎通確認リクエストの場合は、ステータス200を返す
    // リクエストbodyは {
    //   "destination": "xxxxxxxxxx",
    //   "events": []
    // }
    const requestBody = JSON.parse(event.body);
    if (requestBody.events.length === 0) {
        console.log("LINEの疎通確認リクエストのため、ステータス200を返します");
        return {
            statusCode: 200,
            body: JSON.stringify({
                message: 'LINEの疎通確認リクエストのため、ステータス200を返します',
            }),
            headers: {
                'Content-Type': 'application/json',
            },
        };
    }

    // LINEからのリクエストかどうかをチェックする
    if(!checkRequestFromLine(event)) {
        console.log("LINEからのリクエストではないので、処理を終了します");
        return;
    }

    const eventsArray = requestBody.events;
    console.log("eventsArray -> ", eventsArray);

    // 補足説明: events配列は、イベントによっては複数格納されることがあるので、for文で順番に処理する
    for (const eventItem of eventsArray) {
        // userIdの取得
        const userId = eventItem.source.userId;
        console.log("userId -> ", userId);

        // textMessageイベントの処理
        if (eventItem.type === "message") {
            await textMessageEventReply(eventItem);
        }

        // postBackイベントの処理
        if (eventItem.type === "postback") {
            await postBackEventReply(eventItem);
        }

        // followイベントの処理
        if (eventItem.type === "follow") {
            await followEventReply(eventItem);
        }

        // unfollowイベントの処理
        if (eventItem.type === "unfollow") {
            await unfollowEventReply(eventItem);
        }
    }
};

```

- `npm run deploy`でデプロイを忘れずに。

## API Gatewayの設定
- [以前書いた別の記事(LINE BotのWebHookのためのAPI Gateway作成方法)](https://qiita.com/zono345/items/9cae36373049071de0ea) を参照して、設定してください。

## LINE Botの動作確認
- 実際にLINE Botにコメントを送信したり、リッチメニューをタップして動作確認をしてみます。
- 問題なく動作しました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834744/ef9b010a-1794-5155-4bf7-49ff8bc68add.png" width="30%">

- 以上で『LINE Botの自動返信メソッドをLambdaで作る』手順の完了です。