Lambdaを活用した試験管理Lineボットを作ってみました

#やりたいこと
LineボットをAWSサービスと連携させ、直近の試験日程を登録し、試験の一覧を表示する機能を作りたいと考えています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/577ef19d-7478-7f27-07d4-8a8570e2544b.png)

イメージとしては以下↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/5e397902-515b-a9cd-bb86-7d55847b3a99.png)



# ①Lineボット作成＋Messaging APIの有効化
Line関連の前準備は少し複雑なので、別途ブログとしてまとめました～
以下ご覧ください～

https://qiita.com/curlneko/items/b50ed1f98f7ebcb8b4c2

# ②Lambdaの作成：ただのオウム返しボット
最初は、LambdaとLineボットのやり取りの流れを学ぶため、複雑な返答分岐を作らずにただのオウム返しを試してみたいと思います。

Lambdaを開き、「関数の作成」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/e8d3b03c-ec6e-5798-a98c-4aa850f22c95.png)

「一から作成」を選び、関数名入力し、Python3.13で「作成」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/aaa46566-3245-bae1-dc6d-4eb91dc27b5e.png)

作成できたら、以下のソースをデプロイ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/4a0f506c-b629-445e-2adb-9244d54e9bae.png)


```
import os
from linebot import LineBotApi, WebhookParser
from linebot.models import MessageEvent, TextMessage, TextSendMessage

# 環境変数から設定を取得
LINE_CHANNEL_SECRET = os.getenv('LINE_CHANNEL_SECRET')
LINE_CHANNEL_ACCESS_TOKEN = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')

line_bot_api = LineBotApi(LINE_CHANNEL_ACCESS_TOKEN)
parser = WebhookParser(LINE_CHANNEL_SECRET)

def lambda_handler(event, context):
    try:
        print(event)
        print(context)

        # LINEのイベント情報を直接取得
        line_events = event.get("events", [])

        for e in line_events:
            # メッセージイベントの場合のみ処理
            if e["type"] == "message" and e["message"]["type"] == "text":
                reply_token = e["replyToken"]
                user_message = e["message"]["text"]

                # 返信メッセージ作成
                reply_message = TextSendMessage(text=f"あなたのメッセージ: {user_message}")
                line_bot_api.reply_message(reply_token, reply_message)

        return {"statusCode": 200, "body": "OK"}

    except Exception as e:
        print(f"Error: {str(e)}")
        return {"statusCode": 500, "body": str(e)}[title](url)
```
# ③Line Developersでチャンネルアクセストークンを発行
Line Developersで作成した公式アカウントを選択し、Messaging API設定タブをクリックし、画面一番下の「チャネルアクセストークン」の「発行」を押します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/e9ab4611-0aa8-6217-42db-8cb1d7fd7614.png)


# ④Lambdaの環境変数設定
「Lambda関数＞設定＞環境変数」で、以下の環境変数を設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/4eabf0bc-29c6-4820-bf34-dfcb0bcc52b1.png)

LINE_CHANNEL_ACCESS_TOKENは、先ほど発行したチャンネルアクセストークンで設定し、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/80aade7e-9fde-c9bf-4ee7-cd84603ebcff.png)

LINE_CHANNEL_SECRETは、Line Developers＞作成した公式アカウント＞チャネル基本設定で、画面真ん中にある「チャネルシークレット」で設定します。
（公式アカウント作成時に自動で発行されるはずですが、発行されていない場合は「発行」を押して手動発行してください。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/7eacb22e-2aa6-05cb-4505-89be7b766cb5.png)

# ⑤ApiGatewayの構築
ApiGatewayを開き、「APIを作成」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/6e3e8617-7492-70bf-9969-f94bf485620a.png)

「RestAPI」で構築、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/52b380fe-3ad7-64af-49a1-d46569d7e70d.png)

「新しいAPI」をクリックし、API名を入力し、APIエンドポイントを「リージョン」にし、「APIを作成」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/54cef9b9-7b23-370d-5779-d9bc5dab0e28.png)

# ⑥ApiGatewayのリソース+メソッドの作成
出来上がったAPIGatewayはこんな感じです。「リソースを作成」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/a0cba62b-f891-8c69-1bbc-c9f8a31f8f81.png)

リソース名を「line」で入力し、「リソースの作成」
（lineじゃなくても大丈夫です。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/5b21d297-7add-2ce4-d576-ded2b8cbb5a3.png)

リソースが作成されました。次に、リソースの下に「メソッドを作成」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/e171b9c6-572c-2823-b68d-f8391e0b0cc4.png)

メソッドタイプを「POST」で選択し、統合タイプを「Lambda関数」で選択し、先ほど作ったLambda関数を選択し、作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/b47c7843-526c-965a-eccc-d1428517fe2c.png)

すると、メソッドができました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/b3c84e9c-8792-8c73-f93b-31a7f49706f6.png)

「APIをデプロイ」をクリックし、新しいステージとして、ステージ名を入れてデプロイ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/e85ee674-896e-1937-7ff9-2c6212a02920.png)


# ⑦Line DevelopersでMessaging APIのWebhook設定
デプロイできたAPIGatewayのステージを開き、メソッドの「URLを呼び出す」のところをURLをコピー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/33988b14-a720-fc68-069e-a0b4403263bc.png)

Line Developersで作成した公式アカウントの「Messaging API」タブをクリックし、Webhook設定の「編集」をクリックし、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/5ace761b-c029-6394-4a37-8bdde8a65c48.png)

コピーしたAPIGatewayのURLで設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/e91e2f21-bd06-3432-438d-bca591690538.png)

URL設定が終わったら、「Webhookの利用」にチェックを入れます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/ead1abc1-ab23-827c-5328-af384a56b9a6.png)

# ⑧Lineボットのテスト：オウム返し
携帯でLineボットを開き、適当なテストメッセージを入れてみると、
オウム返しの「あなたのメッセージ：テスト」が返されたが、その前に一つ変なメッセージも一緒に返されました。。。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/fdb68d54-e961-dbe4-cebd-96e458e444ba.png)


# ⑨Lineボットのデフォルト応答メッセージの無効化
それは、Lineボットのデフォルト応答メッセージです。。。
作成時に自動で作られて、手動で無効化しない限り、すべてのユーザーメッセージに対して自動返信する仕組みとなっています。。。

Line Official Account Managerを開き、作成した公式アカウントのホーム画面＞ 左メニューの応答メッセージでクリックし、「メッセージありがとうございます！」のやつをクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/72547e3a-ecad-1331-5d81-be5a3613eb3b.png)

「一律応答」で作成されていますね。。。いったん「利用停止」をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/53768bab-96a2-9167-e47c-30f7aa054f20.png)

すると、もう一度携帯のほうでテストメッセージを送ってみたら、今回は変な自動応答なく、想定したオウム返しの返信文のみが返されました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/4b582676-cb32-d51f-ab18-4ae60f5f9032.png)

# ⑩DynamoDB作成
DynamoDBを開き、「テーブルの作成」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/9ff2e5ee-13e3-58e8-844b-657aad91e161.png)

テーブル名を入力し、パーティションキーをidにソートキーをnameに設定し、その他はデフォルトのままで、「テーブルを作成」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/6ee77b84-729d-f228-7290-c4f7ec369b72.png)

作成に成功しました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/79b023a2-c1d3-7493-8885-41bbfab8a5bf.png)

# ⑪Lambdaの作成：試験管理アプリ
ただのオウム返しボットはpythonだけで実装できますが、より複雑な処理分岐をする場合は、「line-bot-sdk」ライブラリが必要です。

ローカル側の作業フォルダに入って、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/16322f9e-1166-1fc4-403f-a54edd1b3d94.png)

以下のコマンドを実行してください。
```
python -m pip install line-bot-sdk -t .
```

すると、フォルダには大量なライブラリがダウンロードされます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/9b44cd8c-e026-95c6-fb01-b58b33533945.png)

ソースコードを以下のように修正します。
```
import os
import re
import boto3
from linebot import LineBotApi, WebhookParser
from linebot.models import MessageEvent, TextMessage, TextSendMessage, TemplateSendMessage, ButtonsTemplate, PostbackAction
from datetime import datetime
import uuid

# DynamoDBクライアント
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.getenv('DYNAMODB_TABLE_NAME') 

# 環境変数からLINEの設定を取得
LINE_CHANNEL_SECRET = os.getenv('LINE_CHANNEL_SECRET')
LINE_CHANNEL_ACCESS_TOKEN = os.getenv('LINE_CHANNEL_ACCESS_TOKEN')

line_bot_api = LineBotApi(LINE_CHANNEL_ACCESS_TOKEN)
parser = WebhookParser(LINE_CHANNEL_SECRET)

# 正しい試験名のフォーマット（yyyy-mm-dd）
exam_name_pattern = re.compile(r"^[\w\sぁ-んァ-ン一-龥々ー]+:\d{4}-\d{2}-\d{2}$")

def lambda_handler(event, context):
    print(event)
    print(context)

    try:
        # LINEのイベント情報を取得
        line_events = event.get("events", [])

        for e in line_events:
            # メッセージイベント
            if e["type"] == "message" and e["message"]["type"] == "text":
                user_message = e["message"]["text"]
                reply_token = e["replyToken"]

                # 「試験登録」の場合
                if user_message == "試験登録":
                    print("試験登録")
                    reply_message = TextSendMessage(text="試験名:yyyy-mm-ddで登録してください。")
                    line_bot_api.reply_message(reply_token, reply_message)

                # 「試験名：yyyy-mm-dd」の形式の場合
                elif exam_name_pattern.match(user_message):
                    print("試験名：yyyy-mm-dd")
                    exam_name, exam_date = user_message.split(":")
                    try:
                        # DynamoDBに試験を登録
                        table.put_item(
                            Item={
                                'id': str(uuid.uuid4()),  # UUIDをパーティションキーとして設定
                                'name': exam_name.strip(),
                                'date': exam_date.strip(),
                                'created_at': datetime.now().isoformat()
                            }
                        )
                        reply_message = TextSendMessage(text="登録しました")
                    except Exception as e:
                        reply_message = TextSendMessage(text="登録に失敗しました。もう一度試してください。")
                    # reply_message = TextSendMessage(text="登録しました")
                    line_bot_api.reply_message(reply_token, reply_message)

                # 「試験一覧」の場合
                elif user_message == "試験一覧":
                    print("試験一覧")
                    # DynamoDBから試験一覧を取得
                    response = table.scan()
                    exams = response.get('Items', [])
                    if exams:
                        exam_list = "\n".join([f"{exam['name']} : {exam['date']}" for exam in exams])
                        reply_message = TextSendMessage(text=f"登録された試験一覧:\n{exam_list}")
                    else:
                        reply_message = TextSendMessage(text="まだ試験は登録されていません。")
                    # reply_message = TextSendMessage(text="まだ試験は登録されていません。")
                    line_bot_api.reply_message(reply_token, reply_message)

                # 上記以外のメッセージの場合
                else:
                    print("上記以外のメッセージの場合")
                    buttons_template = ButtonsTemplate(
                        title="選択してください",
                        text="試験登録または試験一覧を選んでください",
                        actions=[
                            PostbackAction(label="試験登録", data="action=register"),
                            PostbackAction(label="試験一覧", data="action=list")
                        ]
                    )
                    reply_message = TemplateSendMessage(alt_text="試験登録 or 試験一覧", template=buttons_template)
                    line_bot_api.reply_message(reply_token, reply_message)

        # Postbackイベントの処理
        for e in line_events:
            if e["type"] == "postback":
                postback_data = e["postback"]["data"]
                reply_token = e["replyToken"]

                if postback_data == "action=register":
                    reply_message = TextSendMessage(text="試験名:yyyy-mm-ddで登録してください。")
                    line_bot_api.reply_message(reply_token, reply_message)
                elif postback_data == "action=list":
                    # DynamoDBから試験一覧を取得
                    response = table.scan()
                    exams = response.get('Items', [])
                    if exams:
                        exam_list = "\n".join([f"{exam['name']} : {exam['date']}" for exam in exams])
                        reply_message = TextSendMessage(text=f"登録された試験一覧:\n{exam_list}")
                    else:
                        reply_message = TextSendMessage(text="まだ試験は登録されていません。")
                    # reply_message = TextSendMessage(text="まだ試験は登録されていません。")
                    line_bot_api.reply_message(reply_token, reply_message)

        return {"statusCode": 200, "body": "OK"}

    except Exception as e:
        print(f"Error: {str(e)}")
        return {"statusCode": 500, "body": str(e)}
```
ライブラリを含めて、すべてのソースをZip化します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/ca5cdf8b-be0b-f22c-ca1a-a3c0ad9673a0.png)

Lambda関数を開き、「アップロード元」をクリックし、「.zipファイル」を選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/2b59dfe9-cbca-2045-b03c-80c8017465f2.png)

作ったZipファイルをアップロードし、「保存」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/e7c8cea1-d71f-0803-2747-af0af2899c28.png)

### ソースの解説
このように、「試験登録」と「試験一覧」のボタンを押して、操作できるようにします。
（Lineの公式ドキュメントから抜粋）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/b593b0ef-02cd-b711-ecf8-523bffc3a3d3.png)

また、直接「試験登録」と「試験一覧」のテキストを送信するパターンにも備えて、直接操作も対応できるようにしています。

# ⑫LambdaのIAMを修正
Lambda関数＞設定＞アクセス権限で、Lambda関数のデフォルトIAMロールをクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/ead24e92-c3cb-c9fa-64be-f5b1f569bb0e.png)

デフォルトのポリシーをクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/9f6712a3-11fa-5b7b-987b-19d03df9bebb.png)

「編集」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/4387a88e-6e90-0a61-eeb1-887ddd15e6bb.png)

ビジュアルタブをクリックし、「許可をさらに追加」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/adcab36a-97f2-f0f0-c215-9f5ed821ae18.png)

DynamoDBに対して、すべての権限を許可
（今回は検証をスムーズに進めるために広い権限を付与しましたが、実務では最小権限の原則に従うべきだと考えます。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/14a6b58c-c567-3b23-aaac-379b9b78299b.png)

「次へ」＞「変更を保存」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/6e3e3000-fbc8-2e03-d39e-66bb02ed54b7.png)

# ⑬Dynamoテーブル名をLambdaの環境に追加
「Lambda関数＞設定＞環境変数」で、Dynamoテーブル名を追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/9cf09e58-a512-6335-d636-0f97edd793b8.png)

# ⑭Lineボットのテスト：試験管理アプリ
もう一度携帯で公式アカウントを開き、操作してみましょう！

すると、今回は、こんな感じで操作できるようになりました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3538617/852bb33d-b61f-b151-9b29-6d11c4c51b32.png)

# 参考サイト

https://developers.line.biz/en/docs/messaging-api/message-types/#template-messages