メモを DynamoDB へ保存する LINE ボットを作ってみた

サーバーレスの定番構成である API Gateway + Lambda + DynamoDB を LINE ボットから扱うサンプルを作ってみました。
2023.07.27

こんにちは、高崎@アノテーションです。

はじめに

お客様が公開している LINE の各コンテンツの保守作業のオンボーディングとして、先日 LINE でオウム返しを行うボットを作成しブログに公開しましたが、更にここから入力した内容をメモとして DynamoDB へ保存するボットを作成したのでブログ化いたします。

要件と構成について

要件については簡単に下記とします。

  • トークから「list」を先頭にした文字を送ると一覧表示のコマンドが来たとして DB からデータを取得しレスポンスに新しい順に一覧表示する。
  • トークから「regist:」を先頭にした文字を送ると新規保存のコマンドが来たとしてそれ以降の文字を DB に保存する。
  • トークから「delete:」を先頭にした文字を送ると削除のコマンドが来たとして一覧表示した中から指定されたデータを DB から削除する。
  • それ以外にトークから来た文字はオウム返しする。
  • データの保存はユーザごとに5件(レスポンスの応答が5個までなので)とし、最大の状態での追加保存は古いものを削除する。

構成は前回の LINE のオウム返しボットに LINE の各設定を Secrets Manager から取ってくるように修正したソースをベースとして利用します。

アーキテクチャ

サーバレス定番の API Gateway⇔Lambda⇔DynamoDB の構成でエンドポイントを LINE の Webhook に連携して LINE からのボットを DynamoDB へ格納するよう構成します。

データベース構成

構成は下記としました。

ボットに送った LINE ユーザをパーティションキーにして、そこから通し番号でメモを保存するようにしています。

項目 説明 キー 備考
lineuserid 要求元 LINE UserID PK string
id 通し番号 SK number
RegistTime メモ記録時間 number
memo メモ本文 string

コマンド I/F について

ボットへ送るコマンドを下記に定義しました。

コマンド 説明 フォーマット 応答
list 一覧表示 list 以降は何の文字が入っても良い 成功時「通し番号 : メモ」を連続出力
失敗時 エラーレスポンス
regist メモ登録 regist: 以降の文字をメモとして登録する 成功時「〜 の内容を登録しました」
失敗時 エラーレスポンス
delete 削除 delete:通し番号 で指定 成功時「番号 の削除が完了しました」
失敗時 エラーレスポンス

CDK テンプレート

テンプレートのスクリプトは下記になります。

import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { LayerVersion, Function, Runtime, Code } from "aws-cdk-lib/aws-lambda";
import { RestApi, LambdaIntegration } from "aws-cdk-lib/aws-apigateway";
import { Table, AttributeType } from "aws-cdk-lib/aws-dynamodb";
import { Secret } from "aws-cdk-lib/aws-secretsmanager";
import { ScopedAws } from "aws-cdk-lib";

export class LineBotTestStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // example resource
    // Lambda 関数の作成
    const lambdaLayer = LayerVersion.fromLayerVersionArn(this, "lambdaLayer", "arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:4");
    const lambdaMemoBot = new Function(this, "LineMemoBot", {
      runtime: Runtime.NODEJS_18_X,
      handler: "index.handler",
      code: Code.fromAsset("src/lambda"),
      layers: [lambdaLayer],
      environment: {
        PARAMETERS_SECRETS_EXTENSION_HTTP_PORT: "2773",
        PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLED: "true",
        SECRET_ID: "LineAccessInformation",
        TABLE_MAXIMUM_NUMBER_OF_RECORD: "5",
      },
    });

    // DynamoDB の作成
    const dynamoMemoBot = new Table(this, "dynamoMemoTable", {
      tableName: "LineMemoBot_memo",
      partitionKey: { name: "lineuserid", type: AttributeType.STRING },
      sortKey: { name: "id", type: AttributeType.NUMBER },
    });
    // 作ったテーブル名を Lamnbda の環境変数へセット
    lambdaMemoBot.addEnvironment("TABLE_NAME", dynamoMemoBot.tableName);

    // 権限付与
    // Lambda -> Secrets Manager
    const { region, accountId } = new ScopedAws(this);
    const stringSecretName = "LineAccessInformation";
    const stringSecretArn = `arn:aws:secretsmanager:${region}:${accountId}:secret:${stringSecretName}-XXXXXX`;
    const smResource = Secret.fromSecretCompleteArn(this, "SecretsManager", stringSecretArn);
    smResource.grantRead(lambdaMemoBot);
    // Lambda -> DynamoDB
    dynamoMemoBot.grantReadWriteData(lambdaMemoBot);

    // API Gateway の作成
    const api = new RestApi(this, "LineMemoApi", {
      restApiName: "LineMemoApi",
    });
    // proxy ありで API Gateway に渡すインテグレーションを作成
    const lambdaInteg = new LambdaIntegration(lambdaMemoBot, {
      proxy: true,
    });
    // API Gateway の POST イベントと Lambda との紐付け
    api.root.addMethod("POST", lambdaInteg);
  }
}

前回からの追加・変更の箇所は下記になります。

  • import の「* as」を無くして必要なインスタンスを明示するように実装
  • 変数の名前に「Parroting」としていたものを「Memo」へ変更
  • 環境変数に Secrets Manager から取得するための設定や DynamoDB へ登録出来る最大データ数を設定
  • DynamoDB を生成する構文の追加
  • Lambda から DynamoDB へアクセス出来るよう権限を追加

Lambda ソース

Lambda のソースは下記になります。

Lambdaソース(250行近くなったのでたたみました)
import { Client, validateSignature, WebhookRequestBody } from "@line/bot-sdk";
import { Message } from "@line/bot-sdk/lib/types";
import { APIGatewayProxyResult, APIGatewayProxyEvent, Context } from "aws-lambda";
import axios from "axios";
import { DynamoDBClient }  from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand, PutCommand, DeleteCommand } from "@aws-sdk/lib-dynamodb";

// Secrets Manager から取得するための諸々
const cacheEnabled = process.env.PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLED || "false";
const requestCache: boolean = JSON.parse(cacheEnabled.toLowerCase());
const httpPort = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT || "2773";
const requestSecretId = process.env.SECRET_ID || "MySecretId";
const requestEndpoint = `http://localhost:${httpPort}/secretsmanager/get?secretId=${requestSecretId}`;
const requestOptions = {
  headers: {
    "X-Aws-Parameters-Secrets-Token": requestCache? process.env.AWS_SESSION_TOKEN: "",
  },
};

// DynamoDB へアクセスするための諸々
const numMaxRecord: number = Number(process.env.TABLE_MAXIMUM_NUMBER_OF_RECORD) || 5;
const stringTableName: string = process.env.TABLE_NAME || "linememo";
const clientDB = DynamoDBDocumentClient.from(new DynamoDBClient({ region: process.env.AWS_REGION }));

// レスポンス結果(200/成功、500/失敗)を固定で設定
const resultError: APIGatewayProxyResult = {
  statusCode: 500,
  body: "Error",
};
const resultOK: APIGatewayProxyResult = {
  statusCode: 200,
  body: "OK",
};

// DynamoDB から引数のユーザに関する最後に登録した通し番号を取得する
async function getUserTableLastCount(stringUser: string): Promise<number> {
  // 逆順で1つだけ取得
  const command = new QueryCommand({
    TableName: stringTableName,
    KeyConditionExpression: "lineuserid = :userid",
    ExpressionAttributeValues: { ":userid": stringUser },
    ScanIndexForward: false,
    Limit: 1,
  });
  try {
    const response = await clientDB.send(command);
    if (response.Count !== undefined) {
      // 一つもなかった場合は Items が空配列なのでゼロを返す
      if (response.Count === 0) {
        return 0;
      }
      // 値がある場合は id に何らかの値が入っているのでそれを返す
      if (response.Items !== undefined && response.Items[0].id !== undefined) {
        console.log("Items id : ", response.Items[0].id);
        return Number(response.Items[0].id);
      }
    }
    return -1;
  } catch (error) {
    console.error("getUserTableLastCount : ", error);
  }
  return -1;
};

// DynamoDB から引数のユーザに関するデータを全件取得する
interface getUserTableProc {
  stringUser: string;
  numCount?: number,
  boolForward?: boolean;
}
async function getUserTable(argProc: getUserTableProc): Promise<Record<string, any>[]|undefined> {
  // クエリーを発行して取得する
  const command = new QueryCommand({
    TableName: stringTableName,
    KeyConditionExpression: "#lineuserid = :userid",
    ExpressionAttributeNames: { "#lineuserid": "lineuserid" },
    ExpressionAttributeValues: { ":userid": argProc.stringUser },
    Limit: argProc.numCount,
    ScanIndexForward: argProc.boolForward,
  });
  try {
    const { Items: items = [] } = await clientDB.send(command);
    console.log(JSON.stringify(items));
    return items;
  } catch (error) {
    console.error("getUserTable : ", error);
    return undefined;
  }
}

export const handler = async (eventLambda: APIGatewayProxyEvent, contextLambda: Context): Promise<APIGatewayProxyResult> => {
  console.log(JSON.stringify(eventLambda));
  // Secrets Manager から値を取得
  const responseSM = await axios.get(requestEndpoint, requestOptions);
  const jsonSecret = JSON.parse(responseSM.data["SecretString"]);
  const clientLine = new Client({
    channelAccessToken: jsonSecret.ACCESS_TOKEN!,
    channelSecret: jsonSecret.CHANNEL_SECRET,
  });

  const structHeader = JSON.parse(JSON.stringify(eventLambda.headers).replace(/X-Line-Signature/gi, "X-Line-Signature"));
  const stringSignature = structHeader["X-Line-Signature"];
  // Line の署名認証
  if (!validateSignature(eventLambda.body!, clientLine.config.channelSecret!, stringSignature!)) {
    // 署名検証がエラーの場合はログを出してエラー終了
    console.log("署名認証エラー", stringSignature!);
    return resultError;
  }
  // 文面の解析
  const bodyRequest: WebhookRequestBody = JSON.parse(eventLambda.body!);
  if (typeof bodyRequest.events[0] === "undefined") {
    // LINE Developer による Webhook の検証は events が空配列の body で来るのでその場合は 200 を返す
    console.log("Webhook inspection");
    return resultOK;
  }
  if (bodyRequest.events[0].type !== "message" || bodyRequest.events[0].message.type !== "text") {
    // text ではない場合は終了する
    console.log("本文がテキストではない", bodyRequest);
    return resultError;
  } else {
    // 要求メッセージを取得
    const requestText: string = bodyRequest.events[0].message.text;
    // 要求元 Line UserID を取得
    const requestUserId: string = bodyRequest.events[0].source.userId || "";
    // 応答メッセージが動的になるので初期化
    var messageReply: Message[] = [];

    try {
      if (requestText.startsWith("regist:")) {
        const stringMemo = requestText.replace("regist:", "");
        // 保存されているデータで id の最大値を取得
        const numLastId: number = await getUserTableLastCount(requestUserId);
        if (numLastId === -1) {
          // 失敗したら登録せず throw して終了
          throw new Error("最大値取得に失敗")
        }
        // PutItem 発行
        await clientDB.send(
          new PutCommand({
            TableName: stringTableName,
            Item: {
              lineuserid: requestUserId,
              id: numLastId + 1,
              RegistTime: new Date().getTime(),
              memo: stringMemo,
            }
          })
        );
        // UserId のデータを全件取得
        const items = await getUserTable({ stringUser: requestUserId, }) || [];
        // 5件以上あるものは削除(基本的には1件)
        items.splice(-numMaxRecord);
        await Promise.all(
          items.map((element) => {
            const commandDelete = new DeleteCommand({
              TableName: stringTableName,
              Key: {
                lineuserid: element["lineuserid"],
                id: element["id"],
              }
            });
            clientDB.send(commandDelete);
          })
        );
        // 応答メッセージを作成
        messageReply.push({
          type: "text",
          text: stringMemo + " の登録が完了しました",
        });
      } else if (requestText.startsWith("list")) {
        // UserId のデータを全件取得
        // 新しい順なので降順で取得する
        const items = await getUserTable({
          stringUser: requestUserId,
          numCount: numMaxRecord,
          boolForward: false,
        }) || [];
        // 応答メッセージへセット
        items.forEach((responseItem: any) => {
          if (responseItem.memo !== undefined) {
            messageReply.push({
              type: "text",
              text: responseItem.id + " : " + responseItem.memo,
            });
          }
        });
        if (messageReply.length === 0) {
          // 一件も無い場合はレスポンスが空なので応答メッセージを別途設定
          messageReply.push({
            type: "text",
            text: "一覧が存在しません",
          });
        }
      } else if (requestText.startsWith("delete:")) {
        const stringIndex = requestText.replace("delete:", "");
        console.log("lineuserid : ", requestUserId, "id : ", stringIndex);
        // DeleteItem 発行
        const responseDelete = await clientDB.send(
          new DeleteCommand({
            TableName: stringTableName,
            Key: {
              lineuserid: requestUserId,
              id: Number(stringIndex),
            }
          })
        );
        console.log(JSON.stringify(responseDelete));
        // 応答メッセージをセット
        messageReply.push({
          type: "text",
          text: stringIndex + " の削除処理が完了しました",
        });
      } else {
        // オウム返しする場合、1個の配列で応答メッセージをセット
        messageReply.push({
          type: "text",
          text: bodyRequest.events[0].message.text,
        });
      }
    } catch (e) {
      // コンソールにエラーを出しておく
      console.error(e);
      var stringReply = "エラーが発生しました";
      if (e instanceof Error) {
        stringReply = e.message;
      }
      // 応答メッセージをセット
      messageReply.push({
        type: "text",
        text: stringReply,
      });
    }
    // 応答メッセージ送信
    await clientLine.replyMessage(
      bodyRequest.events[0].replyToken,
      messageReply
    );
    // OK 返信をセット
    return resultOK;
  }
};

前回からの追加・変更の箇所は下記になります。

  • CDK と同様、import の「* as」を無くして必要なインスタンスを明示するように実装
  • Secrets Manager から取得する際に環境変数を用意してある程度変動して取得できるよう実装
  • オウム返しする箇所を変更して要件に基づいた実装(下記処理概要に記載)を追加

処理概要

  • データ取得
    • 入ってきたテキストの先頭がlistで始まれば処理する
    • LINE アカウント名をプライマリキーにテーブルから取得する
    • 取得したデータを LINE の応答にid:memoという内容で個々にテキストでセットする
    • 5 件以上設定されるとエラーになるので取得時に 5 件とガードをかけておく
    • 一件も無い場合は「データが無い」旨の LINE の応答へ返す
  • データ登録
    • 入ってきたテキストの先頭がregist:であれば処理する
    • 今テーブルに入っているidの最大値を取得する
    • idに先に取得した値+1、RegistTimeに Date クラスの現在時刻、memoregist:以降の文字をセットして登録
    • 全件取得し、5 件以上あるものを削除(補足参照)
  • データ削除
    • 入ってきたテキストの先頭がdelete:であれは処理する
    • delete:以降の数字をキーにテーブルへ削除を要求する
  • オウム返し
    • 入ってきたテキストが上記以外の場合は応答メッセージに入ってきたメッセージをそのまま返す

補足

登録時に1件登録してから余計な1件を削除する構文は下記のブログを参考にしています。

Promise.allを使わない方がベターかもしれませんが、対象件数が少ないこともあり割り切りでこの表現を拝借しました。

動作確認

では、動作確認です。

登録、一覧表示

5件以上登録、削除

狙った通りの動作をしてくれました。

終わりに

LINE ボットからサーバーレスの定番構成である API Gateway + Lambda + DynamoDB へつながるサンプルを作成してみました。

参考になれば幸いです。

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。
「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。
現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。
少しでもご興味あれば、アノテーション株式会社WEBサイト をご覧ください。