3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【個人開発/LINE Messaging API】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(①)

Last updated at Posted at 2021-08-09

LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。
完成形としては以下の通りです。

名称未設定.png

名称未設定2.png

アーキテクチャ

今回はサーバーレスアーキテクチャの根幹の機能である、「AWS Lambda」, 「Amazon API Gateway」, **「Amazon DynamoDB」**の3つを使用してアプリを作成します。

スクリーンショット 2021-06-26 15.43.25.png

また、プロビジョニングやデプロイに関しては**AWS SAM(Serverless Application Model)**を使用します。

対象読者

・ Node.jsを勉強中の方
・ TypeScriptを勉強中の方
・ インフラ初心者の方
・ ポートフォリオのデプロイ先をどうするか迷っている方

作成の難易度は低めです。
理由は、必要なパッケージも少ないため要件が複雑ではないからです。
また、DynamoDBの操作は、CRUD(作成・読み込み・更新・削除)のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。

記事

今回は2つの記事に分かれています。
お店の検索を行うところまでを今回の記事で行っています。

お気に入り店の登録や解除などを次の記事で行います。

どのようなアプリか

皆さんは、どのようにして飲食店を探しますか?
私は、食べログなどのグルメサイトを使わずに Google Mapで探します。

以前食べログで「星 3.8 問題」がありました。

これだけではなく、食べログで見つけた行ったお店がイマイチだったこともあり、
グルメサイトはお店を探す場所ではなく、お店を予約するためのサイトと私は割り切りました。
電話が苦手な自分としては、まだまだ飲食店で独自の予約サイトを持っている企業も少ないので、食べログやホットペッパーで予約が可能なのはすごく助かっています。

image.png

Google Mapでお店を探すのもなかなか手間がかかるので、今回はGoogle Mapを使って近くの名店を10個教えてくれるアプリを作成しました。

Github

完成形のコードは以下となります。

アプリQR

こちらから触ってみてください。

image.png

アプリの流れ

クライアント LINE Messaging API(バックエンド)
①「お店を探す」をタップ
②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信
③ 現在地を送る
④「車か徒歩どちらですか?」というメッセージを送る
⑤ 車か徒歩を選択
⑥ お店の配列を作成する(車の場合現在地から 14km 以内、徒歩の場合 0.8km 以内)
⑦ 必要なデータのみにする
⑧ 評価順に並び替えて上位 10 店舗にする
⑨ Flex Message を作成する
⑩ お店の情報を Flex Message で送る

ハンズオン!

前提

初めてAWSを使う方に対しての注意です。
ルートユーザーで行うのはよろしくないので、全ての権限を与えたAdministratorユーザーを作っておいてください。

公式サイトはこちらです。

文章は辛いよって方は、初学者のハンズオン動画があるのでこちらからどうぞ。

sam initを実行する

ゼロから書いていってもいいのですが、初めての方はまずはsam initを使いましょう。

以下のように選択していってください。

ターミナル
$ sam init
Which template source would you like to use?
        1 - AWS Quick Start Templates
        2 - Custom Template Location
Choice: 1

What package type would you like to use?
        1 - Zip (artifact is a zip uploaded to S3)
        2 - Image (artifact is an image uploaded to an ECR image repository)
Package type: 1

Which runtime would you like to use?
        1 - nodejs14.x
        2 - python3.8
        3 - ruby2.7
        4 - go1.x
        5 - java11
        6 - dotnetcore3.1
        7 - nodejs12.x
        8 - nodejs10.x
        9 - python3.7
        10 - python3.6
        11 - python2.7
        12 - ruby2.5
        13 - java8.al2
        14 - java8
        15 - dotnetcore2.1
Runtime: 7

Project name [sam-app]: Gourmet

AWS quick start application templates:
        1 - Hello World Example
        2 - Step Functions Sample App (Stock Trader)
        3 - Quick Start: From Scratch
        4 - Quick Start: Scheduled Events
        5 - Quick Start: S3
        6 - Quick Start: SNS
        7 - Quick Start: SQS
        8 - Quick Start: App Backend using TypeScript
        9 - Quick Start: Web Backend
Template selection: 1

ここまでできれば作成されます。
このような構成になっていればOKです。

.Gourmet
├── events/
│   ├── event.json
├── hello-world/
│   ├── tests
│   │        └── integration
│   │        │        └── test-api-gateway.js
│   │        └── unit
│   │        │        └── test-handler.js
│   ├── .npmignore
│   ├── app.js
│   ├── package.json
├── .gitignore
├── README.md
├── template.yaml

必要ないファイルなどがあるのでそれを削除していきましょう。

.Gourmet
├── hello-world/
│   ├── app.js
├── .gitignore
├── README.md
├── template.yaml

また、ディレクトリ名やファイル名を変えましょう。

.Gourmet
├── api/
│   ├── index.js
├── .gitignore
├── README.md
├── template.yaml

次は、template.yamlを修正して、SAMの実行をしてみたいところですが、一旦後回しにします。

image.png

先にTypeScriptなどのパッケージを入れ、ディレクトリ構造を明確にした後の方が理解しやすいので。。

ということでパッケージを入れていきましょう。

package.jsonの作成

以下のコマンドを入力してください。
これで、package.jsonの作成が完了します。

ターミナル
$ npm init -y

必要なパッケージのインストール

dependencies

dependenciesはすべてのステージで使用するパッケージです。

今回使用するパッケージは以下の4つです。
・@line/bot-sdk
・aws-sdk
・axios

以下のコマンドを入力してください。
これで全てのパッケージがインストールされます。

ターミナル
$ npm install @line/bot-sdk aws-sdk axios --save

devDependencies

devDependenciesはコーディングステージのみで使用するパッケージです。

今回使用するパッケージは以下の5つです。
・typescript
・@types/node
・ts-node
・rimraf
・npm-run-all

以下のコマンドを入力してください。
これで全てのパッケージがインストールされます。

ターミナル
$ npm install -D typescript @types/node ts-node rimraf npm-run-all

package.jsonにコマンドの設定を行う

npm run buildでコンパイルを行います。

package.json
{
  "scripts": {
    "clean": "rimraf dist",
    "tsc": "tsc",
    "build": "npm-run-all clean tsc"
  },
}

tsconfig.jsonの作成

以下のコマンドを実行しTypeScriptの初期設定を行います。

ターミナル
$ npx tsc --init

それでは、作成されたtsconfig.jsonの上書きをしていきます。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2018",
    "module": "commonjs",
    "sourceMap": true,
    "outDir": "./api/dist",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["api/src/*"]
}

簡単にまとめると、
api/srcディレクトリ以下を対象として、それらをapi/distディレクトリにES2018の書き方でビルドされるという設定です。

tsconfig.jsonに関して詳しく知りたい方は以下のサイトをどうぞ。

また、この辺りで必要ないディレクトリはGithubにpushしたくないので、.gitignoreも作成しておきましょう。

.gitignore
node_modules
package-lock.json
.aws-sam
samconfig.toml
dist

最終的にはこのようなディレクトリ構成にしましょう。

.Gourmet
├── api/
│   ├── dist(コンパイル後)
│   │        └── node_modules(コピーする)
│   │        └── package.json(コピーする)
│   ├── src(コンパイル前)
│   │        └── index.ts
├── node_modules(コピー元)
├── .gitignore
├── package.json(コピー元)
├── package-lock.json
├── README.md
├── template.yaml
├── tsconfig.json

やるべきことは以下の2つです。
distディレクトリを作成する
distディレクトリに、node_modules, package.jsonをコピーする

次に、template.yamlを書いていきましょう。

SAM Templateを記載する

ファイル内にコメントを残しています。
これで大まかには理解できるかと思います。
詳しくは公式サイトを見てください。

template.yaml
# AWS CloudFormationテンプレートのバージョン
AWSTemplateFormatVersion: '2010-09-09'
# CloudFormationではなくSAMを使うと明記する
Transform: AWS::Serverless-2016-10-31
# CloudFormationのスタックの説明文(重要ではないので適当でOK)
Description: >
  LINE Messaging API + Node.js + TypeScript + SAM(Lambda, API Gateway, DynamoDB, S3)で作った飲食店検索アプリです

Globals:
  # Lambda関数のタイムアウト値(3秒に設定)
  Function:
    Timeout: 3

Resources:
  # API Gateway
  GourmetAPI:
    # Typeを指定する(今回はAPI Gateway)
    Type: AWS::Serverless::Api
    Properties:
      # ステージ名(APIのURLの最後にこのステージ名が付与されます)
      StageName: v1

  # Lambda
  GourmetFunction:
    # Typeを指定する(今回はLambda)
    Type: AWS::Serverless::Function
    Properties:
      # 関数が格納されているディレクトリ(今回はコンパイル後なので、distディレクトリを使用する)
      CodeUri: api/dist
      # ファイル名と関数名(今回はファイル名がindex.js、関数名がexports.handlerなので、index.handlerとなります)
      Handler: index.handler
      # どの言語とどのバージョンを使用するか
      Runtime: nodejs12.x
      # ポリシーを付与する(今回はLambdaの権限とSSMの読み取り権限を付与)
      Policies:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess
      # この関数をトリガーするイベントを指定します
      Events:
        # API Gateway
        GourmetAPI:
          Type: Api
          Properties:
            # どのAPIを使用するか(!Refは値の参照に使用します)
            RestApiId: !Ref GourmetAPI
            # URL
            Path: /
            # POSTメソッド
            Method: post

Outputs:
  GourmetAPI:
    Description: 'API Gateway'
    Value: !Sub 'https://${GourmetAPI}.execute-api.${AWS::Region}.amazonaws.com/v1'
  GourmetFunction:
    Description: 'Lambda'
    Value: !GetAtt GourmetFunction.Arn
  GourmetFunctionIamRole:
    Description: 'IAM Role'
    Value: !GetAtt GourmetFunctionRole.Arn

LINE Developersにアカウントを作成する

LINE Developersにアクセスして、「ログイン」ボタンをクリックしてください。

その後諸々入力してもらったら以下のように作成できるかと思います。
注意事項としては、今回Messaging APIとなるので、チャネルの種類を間違えた方は修正してください。

スクリーンショット 2021-07-26 14.10.05.png

チャネルシークレットチャネルアクセストークンが必要になるのでこの2つを発行します。

スクリーンショット 2021-05-29 16.16.20.png

スクリーンショット 2021-05-29 16.17.51.png

これで必要な環境変数は取得できました。
それでは、これをSSMを使ってLambda内で使えるようにしていきましょう。

SSMパラメータストアで環境変数を設定

なぜSSMパラメータストアを使うのか?

SAMのLambda設定にも、環境変数の項目はあります。

しかし、2点問題点があります。
①Lambdaの環境変数の変更をしたいとき、Lambdaのバージョンも新規発行をしなければならない
②Lambdaのバージョンとエイリアスを紐付けて管理をするとき、もし環境変数にリリース先環境別の値をセットしていると、リリース時に手動で環境変数の変更をしなければならないケースが発生する

簡単にまとめると、**「リアルタイムで反映できないし、人為的なミスのリスクもあるよ」**ということです。

SSMパラメータストアで値を管理すると以下の3点のメリットがあります。
①Lambdaの環境変数の管理が不要
②Lambdaも含めた値関連情報を一元管理できる
③Lambda外部からリアルタイムに環境変数を変更制御できる

ということで、SSMパラメータストアを使用しましょう。

みんな大好きクラスメソッドの記事にやり方が書いてあります。
こちらの記事が完璧なのでこちらを見てやってみてください。

私は以下のように命名して作成しました。

スクリーンショット 2021-08-05 22.31.49.png

SSMパラメータが取得できているかconsole.logで検証

api/src/index.ts
// import
import aws from 'aws-sdk';

// SSM
const ssm = new aws.SSM();

exports.handler = async (event: any, context: any) => {
  const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
    Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
    WithDecryption: false,
  };

  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  
  console.log('channelAccessToken: ' + channelAccessToken);
};

これをコンパイルしてデプロイしていきましょう。

ターミナル
// コンパイル
$ npm run build

// ビルド
$ sam build

// デプロイ
$ sam deploy --guided
Configuring SAM deploy
======================

        Looking for samconfig.toml :  Not found

        Setting default arguments for 'sam deploy'
        =========================================
        // CloudFormation スタック名の指定
        Stack Name [sam-app]: Gourmet

        // リージョンの指定
        AWS Region [us-east-1]: ap-northeast-1

        // デプロイ前にCloudformationの変更セットを確認するか
        #Shows you resources changes to be deployed and require a 'Y' to initiate deploy
        Confirm changes before deploy [y/N]: y

        // SAM CLI に IAM ロールの作成を許可するか(CAPABILITY_IAM)
        #SAM needs permission to be able to create roles to connect to the resources in your template
        Allow SAM CLI IAM role creation [Y/n]: y

        // API イベントタイプの関数に認証が含まれていない場合、警告される
        HelloWorldFunction may not have authorization defined, Is this okay? [y/N]: y
        
        // この設定を samconfig.toml として保存するか
        Save arguments to samconfig.toml [Y/n]: y

これでデプロイが完了します。
では、API GatewayのURLを確認しましょう。

スクリーンショット 2021-07-20 16.51.25.png

Webhook URLの登録

先ほどAPI Gatewayで作成したhttpsのURLをコピーしてください。

これをLINE DevelopersのWebhookに設定します。

スクリーンショット 2021-07-14 16.30.04.png

それではSSMパラメータが正しく取得できているか確認しましょう。
CloudWatchで確認しましょう!

スクリーンショット 2021-07-20 16.54.55.png

取得できていますね!

これで準備は完了です。
ここから飲食店検索の仕組みを作っていきましょう!

アプリの流れ

クライアント LINE Messaging API(バックエンド)
①「お店を探す」をタップ
②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信
③ 現在地を送る
④「車か徒歩どちらですか?」というメッセージを送る
⑤ 車か徒歩を選択
⑥ お店の配列を作成する(車の場合現在地から 14km 以内、徒歩の場合 0.8km 以内)
⑦ 必要なデータのみにする
⑧ 評価順に並び替えて上位 10 店舗にする
⑨ Flex Message を作成する
⑩ お店の情報を Flex Message で送る

①「お店を探す」をタップ

こちらに関してはクライアント側の操作なので作業することはありません。

②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信

「現在地を送る」ためのボタンメッセージ

api/src/Common/TemplateMessage/YourLocation.ts
// Load the package
import { TemplateMessage } from '@line/bot-sdk';

export const yourLocationTemplate = (): Promise<TemplateMessage> => {
  return new Promise((resolve, reject) => {
    const params: TemplateMessage = {
      type: 'template',
      altText: '現在地を送ってください!',
      template: {
        type: 'buttons',
        text: '今日はどこでご飯を食べる?',
        actions: [
          {
            type: 'uri',
            label: '現在地を送る',
            uri: 'https://line.me/R/nv/location/',
          },
        ],
      },
    };

    resolve(params);
  });
};

ちなみに以下のURLですが、LINEで利用できるURLスキームというもので位置情報を送れるものです。
https://line.me/R/nv/location/

詳しくは以下をご確認ください。

エラーメッセージ

api/src/Common/TemplateMessage/Error.ts
// Load the package
import { TextMessage } from '@line/bot-sdk';

export const errorTemplate = (): Promise<TextMessage> => {
  return new Promise((resolve, reject) => {
    const params: TextMessage = {
      type: 'text',
      text: 'ごめんなさい、このメッセージには対応していません',
    };

    resolve(params);
  });
};

メッセージの送信

api/src/index.ts
// Load the package
import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk';
import aws from 'aws-sdk';

// Load the module
// TemplateMessage
import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation';
import { errorTemplate } from './Common/TemplateMessage/Error';

// SSM
const ssm = new aws.SSM();
const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
  Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
  WithDecryption: false,
};
const LINE_GOURMET_CHANNEL_SECRET = {
  Name: 'LINE_GOURMET_CHANNEL_SECRET',
  WithDecryption: false,
};

exports.handler = async (event: any, context: any) => {
  // Retrieving values in the SSM parameter store
  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();
  const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  const channelSecret: string = CHANNEL_SECRET.Parameter.Value;

  // Create a client using the SSM parameter store
  const clientConfig: ClientConfig = {
    channelAccessToken: channelAccessToken,
    channelSecret: channelSecret,
  };
  const client = new Client(clientConfig);

  // body
  const body: any = JSON.parse(event.body);
  const response: WebhookEvent = body.events[0];

  // action
  try {
    await actionLocationOrError(client, response);
  } catch (err) {
    console.log(err);
  }
};

const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const text = event.message.text;

    // modules
    const yourLocation = await yourLocationTemplate();
    const error = await errorTemplate();

    // Perform a conditional branch
    if (text === 'お店を探す') {
      await client.replyMessage(replyToken, yourLocation);
    } else {
      await client.replyMessage(replyToken, error);
    }
  } catch (err) {
    console.log(err);
  }
};

③ 現在地を送る

こちらに関してもクライアント側の操作なので作業することはありません。

④「車か徒歩どちらですか?」というメッセージを送る

LINE Messaging APIにキャッシュの機能などはありません。
なので、③の**「現在地を送る」**のデータはどこかに格納しないと値が消えてしまいます。

ということで、今回はサーバーレスと相性の良い**「DynamoDB」**を使用します。

DynamoDB

以下のテーブルを作成します。

PK K K K
user_id latitude longitude is_car
ユーザー ID 緯度 経度 車か徒歩か
それぞれのデータ取得方法

ユーザーIDは、event.source.userIdから取得できます。

緯度、経度は、【クライアント】③ 現在地を送るから取得できます。
車か徒歩かは、【クライアント】⑤ 車か徒歩を選択から取得できます。

SAMテンプレートにDynamoDBの記載を行う

template.yaml
# AWS CloudFormationテンプレートのバージョン
AWSTemplateFormatVersion: '2010-09-09'
# CloudFormationではなくSAMを使うと明記する
Transform: AWS::Serverless-2016-10-31
# CloudFormationのスタックの説明文(重要ではないので適当でOK)
Description: >
  LINE Messaging API + Node.js + TypeScript + SAM(Lambda, API Gateway, DynamoDB, S3)で作った飲食店検索アプリです

Globals:
  # Lambda関数のタイムアウト値(3秒に設定)
  Function:
    Timeout: 3

Resources:
  # API Gateway
  GourmetAPI:
    # Typeを指定する(今回はAPI Gateway)
    Type: AWS::Serverless::Api
    Properties:
      # ステージ名(APIのURLの最後にこのステージ名が付与されます)
      StageName: v1

+ # DynamoDB
+ GourmetDynamoDB:
+   # Typeを指定する(今回はDynamoDB)
+   Type: AWS::Serverless::SimpleTable
+   Properties:
+     # テーブルの名前
+     TableName: Gourmets
+     # プライマリキーの設定(名前とプライマリキーのタイプ)
+     PrimaryKey:
+       Name: user_id
+       Type: String
+     # プロビジョニングされたキャパシティの設定(今回の要件では最小の1でOK)
+     ProvisionedThroughput:
+       ReadCapacityUnits: 1
+       WriteCapacityUnits: 1

  # Lambda
  GourmetFunction:
    # Typeを指定する(今回はLambda)
    Type: AWS::Serverless::Function
    Properties:
      # 関数が格納されているディレクトリ(今回はコンパイル後なので、distディレクトリを使用する)
      CodeUri: api/dist
      # ファイル名と関数名(今回はファイル名がindex.js、関数名がexports.handlerなので、index.handlerとなります)
      Handler: index.handler
      # どの言語とどのバージョンを使用するか
      Runtime: nodejs12.x
      # ポリシーを付与する(今回はLambdaの権限とSSMの読み取り権限とDynamoDBのフルアクセス権限を付与)
      Policies:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess
+       - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
      # この関数をトリガーするイベントを指定します
      Events:
        # API Gateway
        GourmetAPI:
          Type: Api
          Properties:
            # どのAPIを使用するか(!Refは値の参照に使用します)
            RestApiId: !Ref GourmetAPI
            # URL
            Path: /
            # POSTメソッド
            Method: post

Outputs:
  GourmetAPI:
    Description: 'API Gateway'
    Value: !Sub 'https://${GourmetAPI}.execute-api.${AWS::Region}.amazonaws.com/v1'
  GourmetFunction:
    Description: 'Lambda'
    Value: !GetAtt GourmetFunction.Arn
  GourmetFunctionIamRole:
    Description: 'IAM Role'
    Value: !GetAtt GourmetFunctionRole.Arn

現在地が送信されたらDynamoDBのuser_id, latitude, longitudeが入力されるようにする

今回はDynamoDBに新規のレコードを追加します。
新規追加はputを使用します。

api/src/Common/Database/PutLocation.ts
// Load the package
import aws from 'aws-sdk';

// Create DynamoDB document client
const docClient = new aws.DynamoDB.DocumentClient();

export const putLocation = (userId: string | undefined, latitude: string, longitude: string) => {
  return new Promise((resolve, reject) => {
    const params = {
      Item: {
        user_id: userId,
        latitude: latitude,
        longitude: longitude,
      },
      ReturnConsumedCapacity: 'TOTAL',
      TableName: 'Gourmets',
    };

    docClient.put(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};

この関数をindex.tsで読み込みましょう。

api/src/index.ts
// Load the package
import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk';
import aws from 'aws-sdk';

// Load the module
// TemplateMessage
import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation';
import { errorTemplate } from './Common/TemplateMessage/Error';
// Database
+ import { putLocation } from './Common/Database/PutLocation';

// SSM
const ssm = new aws.SSM();
const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
  Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
  WithDecryption: false,
};
const LINE_GOURMET_CHANNEL_SECRET = {
  Name: 'LINE_GOURMET_CHANNEL_SECRET',
  WithDecryption: false,
};

exports.handler = async (event: any, context: any) => {
  // Retrieving values in the SSM parameter store
  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();
  const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  const channelSecret: string = CHANNEL_SECRET.Parameter.Value;

  // Create a client using the SSM parameter store
  const clientConfig: ClientConfig = {
    channelAccessToken: channelAccessToken,
    channelSecret: channelSecret,
  };
  const client = new Client(clientConfig);

  // body
  const body: any = JSON.parse(event.body);
  const response: WebhookEvent = body.events[0];

  // action
  try {
    await actionLocationOrError(client, response);
+   await actionIsCar(client, response);
  } catch (err) {
    console.log(err);
  }
};

const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const text = event.message.text;

    // modules
    const yourLocation = await yourLocationTemplate();
    const error = await errorTemplate();

    // Perform a conditional branch
    if (text === 'お店を探す') {
      await client.replyMessage(replyToken, yourLocation);
    } else {
      await client.replyMessage(replyToken, error);
    }
  } catch (err) {
    console.log(err);
  }
};

+ const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => {
+ try {
+   // If the message is different from the target, returned
+   if (event.type !== 'message' || event.message.type !== 'location') {
+     return;
+   }
+
+   // Retrieve the required items from the event
+   const replyToken = event.replyToken;
+   const userId = event.source.userId;
+   const latitude: string = String(event.message.latitude);
+   const longitude: string = String(event.message.longitude);
+
+   // Register userId, latitude, and longitude in DynamoDB
+  await putLocation(userId, latitude, longitude);
+ } catch (err) {
+   console.log(err);
+ }
+ };

これでDynamoDBへの登録が完了です。

次にメッセージを作成しましょう。

api/src/Common/TemplateMessage/IsCar.ts
// Load the package
import { TemplateMessage } from '@line/bot-sdk';

export const isCarTemplate = (): Promise<TemplateMessage> => {
  return new Promise((resolve, reject) => {
    const params: TemplateMessage = {
      type: 'template',
      altText: 'あなたの移動手段は?',
      template: {
        type: 'confirm',
        text: 'あなたの移動手段は?',
        actions: [
          {
            type: 'message',
            label: '',
            text: '',
          },
          {
            type: 'message',
            label: '徒歩',
            text: '徒歩',
          },
        ],
      },
    };

    resolve(params);
  });
};

最後にこちらの関数をindex.tsに読み込みましょう。

api/src/index.ts
// Load the package
import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk';
import aws from 'aws-sdk';

// Load the module
// TemplateMessage
import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation';
import { errorTemplate } from './Common/TemplateMessage/Error';
+ import { isCarTemplate } from './Common/TemplateMessage/IsCar';
// Database
import { putLocation } from './Common/Database/PutLocation';

// SSM
const ssm = new aws.SSM();
const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
  Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
  WithDecryption: false,
};
const LINE_GOURMET_CHANNEL_SECRET = {
  Name: 'LINE_GOURMET_CHANNEL_SECRET',
  WithDecryption: false,
};

exports.handler = async (event: any, context: any) => {
  // Retrieving values in the SSM parameter store
  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();
  const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  const channelSecret: string = CHANNEL_SECRET.Parameter.Value;

  // Create a client using the SSM parameter store
  const clientConfig: ClientConfig = {
    channelAccessToken: channelAccessToken,
    channelSecret: channelSecret,
  };
  const client = new Client(clientConfig);

  // body
  const body: any = JSON.parse(event.body);
  const response: WebhookEvent = body.events[0];

  // action
  try {
    await actionLocationOrError(client, response);
    await actionIsCar(client, response);
  } catch (err) {
    console.log(err);
  }
};

const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const text = event.message.text;

    // modules
    const yourLocation = await yourLocationTemplate();
    const error = await errorTemplate();

    // Perform a conditional branch
    if (text === 'お店を探す') {
      await client.replyMessage(replyToken, yourLocation);
+   } else if (text === '' || text === '徒歩') {
+     return;
+   } else {
      await client.replyMessage(replyToken, error);
    }
  } catch (err) {
    console.log(err);
  }
};

const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'location') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const latitude: string = String(event.message.latitude);
    const longitude: string = String(event.message.longitude);

    // Register userId, latitude, and longitude in DynamoDB
    await putLocation(userId, latitude, longitude);

+   // modules
+   const isCar = await isCarTemplate();

+   // Send a two-choice question
+   await client.replyMessage(replyToken, isCar);
  } catch (err) {
    console.log(err);
  }
};

⑤ 車か徒歩を選択

こちらに関してもクライアント側の操作なので作業することはありません。

⑥ お店の配列を作成する

車の場合現在地から 14km以内、徒歩の場合 0.8km以内で検索することとします。
車は20分程度、徒歩は10分程度で着く範囲を検索対象としています。

移動手段が送信されたらDynamoDBのis_carが入力されるようにする

今回はDynamoDBにuser_idをキーとして、レコードを更新します。
更新はupdateを使用します。

ではやっていきましょう。

api/src/Common/Database/UpdateIsCar.ts
// Load the package
import aws from 'aws-sdk';

// Create DynamoDB document client
const docClient = new aws.DynamoDB.DocumentClient();

export const updateIsCar = (userId: string | undefined, isCar: string) => {
  return new Promise((resolve, reject) => {
    const params = {
      TableName: 'Gourmets',
      Key: {
        user_id: userId,
      },
      UpdateExpression: 'SET is_car = :i',
      ExpressionAttributeValues: {
        ':i': isCar,
      },
    };

    docClient.update(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};

このDB処理をindex.tsで読み込みます。

api/src/index.ts
// Load the package
import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk';
import aws from 'aws-sdk';

// Load the module
// TemplateMessage
import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation';
import { errorTemplate } from './Common/TemplateMessage/Error';
import { isCarTemplate } from './Common/TemplateMessage/IsCar';
// Database
import { putLocation } from './Common/Database/PutLocation';
+ import { updateIsCar } from './Common/Database/UpdateIsCar';

// SSM
const ssm = new aws.SSM();
const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
  Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
  WithDecryption: false,
};
const LINE_GOURMET_CHANNEL_SECRET = {
  Name: 'LINE_GOURMET_CHANNEL_SECRET',
  WithDecryption: false,
};

exports.handler = async (event: any, context: any) => {
  // Retrieving values in the SSM parameter store
  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();
  const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  const channelSecret: string = CHANNEL_SECRET.Parameter.Value;

  // Create a client using the SSM parameter store
  const clientConfig: ClientConfig = {
    channelAccessToken: channelAccessToken,
    channelSecret: channelSecret,
  };
  const client = new Client(clientConfig);

  // body
  const body: any = JSON.parse(event.body);
  const response: WebhookEvent = body.events[0];

  // action
  try {
    await actionLocationOrError(client, response);
    await actionIsCar(client, response);
    await actionFlexMessage(client, response);
  } catch (err) {
    console.log(err);
  }
};

const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const text = event.message.text;

    // modules
    const yourLocation = await yourLocationTemplate();
    const error = await errorTemplate();

    // Perform a conditional branch
    if (text === 'お店を探す') {
      await client.replyMessage(replyToken, yourLocation);
    } else if (text === '' || text === '徒歩') {
      return;
    } else {
      await client.replyMessage(replyToken, error);
    }
  } catch (err) {
    console.log(err);
  }
};

const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'location') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const latitude: string = String(event.message.latitude);
    const longitude: string = String(event.message.longitude);

    // Register userId, latitude, and longitude in DynamoDB
    await putLocation(userId, latitude, longitude);

    // modules
    const isCar = await isCarTemplate();

    // Send a two-choice question
    await client.replyMessage(replyToken, isCar);
  } catch (err) {
    console.log(err);
  }
};

+ const actionFlexMessage = async (client: Client, event: WebhookEvent) => {
+ try {
+   // If the message is different from the target, returned
+   if (event.type !== 'message' || event.message.type !== 'text') {
+     return;
+   }
+
+   // Retrieve the required items from the event
+   const replyToken = event.replyToken;
+   const userId = event.source.userId;
+   const isCar = event.message.text;
+   
+   // Perform a conditional branch
+   if (isCar === '' || isCar === '徒歩') {
+     // Register userId, isCar in DynamoDB
+     await updateIsCar(userId, isCar);
+   } else {
+     return;
+   }
+ } catch (err) {
+   console.log(err);
+ }
+ };

お店の配列を作成するまでのステップ

1. DynamoDBのデータを取得する
api/src/Common/TemplateMessage/Gourmet/GetDatabaseInfo.ts
// Load the package
import aws from 'aws-sdk';

// Create DynamoDB document client
const docClient = new aws.DynamoDB.DocumentClient();

export const getDatabaseInfo = async (userId: string | undefined) => {
  return new Promise((resolve, reject) => {
    const params = {
      TableName: 'Gourmets',
      Key: {
        user_id: userId,
      },
    };

    docClient.get(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};
2. Google Map APIを取得して、SSMパラメーターストアに登録する

Google MapのAPIを取得しましょう。

まずはGCPのコンソール画面に入って下さい。

スクリーンショット 2021-06-24 10.52.19.png

コンソールに入ったらプロジェクトを作成しましょう!

スクリーンショット 2021-06-24 10.53.41.png

私は、LINE-Node-TypeScript-Gourmetで作成しました。

スクリーンショット 2021-06-24 10.56.50.png

では、ライブラリを有効化しましょう!
使うライブラリは2つです。
Map JavaScript API
Places API
お店検索をするAPIは「Places API」ですが、
JavaScriptから呼び出すために「Map JavaScript API」が必要となります。

スクリーンショット 2021-06-24 11.02.05.png

スクリーンショット 2021-06-24 11.03.16.png

ここまでできたら次にAPIを作成しましょう。

スクリーンショット 2021-06-24 11.04.26.png

これからの開発はこちらのAPIキーを使います。
セキュリティ的には制限をつけたほうがいいのですが、今回はつけずに行います。

スクリーンショット 2021-06-24 11.06.45.png

上記の説明でわからなければ以下のサイトを参考にされて下さい。

では取得したAPIをSSMパラメーターストアに登録しましょう。
方法は以下の通りです。

私はこのように命名しました。

スクリーンショット 2021-08-06 10.23.47.png

ではこの値を関数内で使えるようにしましょう。

api/src/index.ts
// Load the package
import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk';
import aws from 'aws-sdk';

// Load the module
// TemplateMessage
import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation';
import { errorTemplate } from './Common/TemplateMessage/Error';
import { isCarTemplate } from './Common/TemplateMessage/IsCar';
// Database
import { putLocation } from './Common/Database/PutLocation';
import { updateIsCar } from './Common/Database/UpdateIsCar';

// SSM
const ssm = new aws.SSM();
const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
  Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
  WithDecryption: false,
};
const LINE_GOURMET_CHANNEL_SECRET = {
  Name: 'LINE_GOURMET_CHANNEL_SECRET',
  WithDecryption: false,
};
+ const LINE_GOURMET_GOOGLE_MAP_API = {
+ Name: 'LINE_GOURMET_GOOGLE_MAP_API',
+ WithDecryption: false,
+ };

exports.handler = async (event: any, context: any) => {
  // Retrieving values in the SSM parameter store
  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();
  const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise();
+ const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  const channelSecret: string = CHANNEL_SECRET.Parameter.Value;
+ const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value;

  // Create a client using the SSM parameter store
  const clientConfig: ClientConfig = {
    channelAccessToken: channelAccessToken,
    channelSecret: channelSecret,
  };
  const client = new Client(clientConfig);

  // body
  const body: any = JSON.parse(event.body);
  const response: WebhookEvent = body.events[0];

  // action
  try {
    await actionLocationOrError(client, response);
    await actionIsCar(client, response);
    await actionFlexMessage(client, response, googleMapApi);
  } catch (err) {
    console.log(err);
  }
};

const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const text = event.message.text;

    // modules
    const yourLocation = await yourLocationTemplate();
    const error = await errorTemplate();

    // Perform a conditional branch
    if (text === 'お店を探す') {
      await client.replyMessage(replyToken, yourLocation);
    } else if (text === '' || text === '徒歩') {
      return;
    } else {
      await client.replyMessage(replyToken, error);
    }
  } catch (err) {
    console.log(err);
  }
};

const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'location') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const latitude: string = String(event.message.latitude);
    const longitude: string = String(event.message.longitude);

    // Register userId, latitude, and longitude in DynamoDB
    await putLocation(userId, latitude, longitude);

    // modules
    const isCar = await isCarTemplate();

    // Send a two-choice question
    await client.replyMessage(replyToken, isCar);
  } catch (err) {
    console.log(err);
  }
};

+ const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const isCar = event.message.text;

    // Perform a conditional branch
    if (isCar === '' || isCar === '徒歩') {
      // Register userId, isCar in DynamoDB
      await updateIsCar(userId, isCar);
    } else {
      return;
    }
  } catch (err) {
    console.log(err);
  }
};
3. お店の配列を作成する

近隣のお店を調べるので、Place SearchNearby Search requestsを使います。

ここが正直イマイチなコードかもしれません。
setTimeoutを頻発しているからです。

Nearby Search requestsは20店舗しか取り出すことができないのですが、
pagetokenを使用することで60店舗取り出すことができます。

このpagetokenを使って再度呼び出しを行うのですが、その時に待ち時間が必要になります。
最初は、async, awaitの非同期で対応できると思っていたのですが、この待ち時間だけでは足りないようでsetTimeoutが必要になりました。

こちらはコードがイマイチなので、対応を考えて他の方法があれば修正いたします。
ここはこんなコードの書き方もあるんだ程度にしていただけますと幸いです。

api/src/Common/TemplateMessage/Gourmet/GetGourmetInfo.ts
// Load the package
import axios, { AxiosResponse } from 'axios';

// Load the module
import { getDatabaseInfo } from './GetDatabaseInfo';

export const getGourmetInfo = async (user_id: string | undefined, googleMapApi: string) => {
  return new Promise(async (resolve, reject) => {
    // modules getDatabaseInfo
    const data: any = await getDatabaseInfo(user_id);
    const isCar = data.Item.is_car;
    const latitude = data.Item.latitude;
    const longitude = data.Item.longitude;

    // Bifurcate the radius value depending on whether you are driving or walking
    let radius = 0;
    if (isCar === '') {
      radius = 1400;
    } else {
      radius = 800;
    }

    let gourmetArray: any[] = [];
    const url = `https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${latitude},${longitude}&radius=${radius}&type=restaurant&key=${googleMapApi}&language=ja`;

    new Promise(async (resolve) => {
      const gourmets: AxiosResponse<any> = await axios.get(url);

      const gourmetData = gourmets.data.results;
      gourmetArray = gourmetArray.concat(gourmetData);

      const pageToken = gourmets.data.next_page_token;
      resolve(pageToken);
    })
      .then((value) => {
        return new Promise((resolve) => {
          setTimeout(async () => {
            const addTokenUrl = `${url}&pagetoken=${value}`;
            const gourmets = await axios.get(addTokenUrl);

            const gourmetData = gourmets.data.results;
            gourmetArray = gourmetArray.concat(gourmetData);

            const pageToken = gourmets.data.next_page_token;
            resolve(pageToken);
          }, 2000);
        });
      })
      .then((value) => {
        setTimeout(async () => {
          const addTokenUrl = `${url}&pagetoken=${value}`;
          const gourmets = await axios.get(addTokenUrl);

          const gourmetData = gourmets.data.results;
          gourmetArray = gourmetArray.concat(gourmetData);
        }, 2000);
      });

    setTimeout(() => {
      resolve(gourmetArray);
    }, 8000);
  });
};

⑦ 必要なデータのみにする

使うデータは以下の通りです。

必要なデータ 理由
geometry_location_lat 店舗案内のURLで使うため
geometry_location_lng 店舗案内のURLで使うため
name Flex Message内と店舗詳細のURLで使うため
photo_reference 店舗写真を生成するために使う
rating Flex Message内で使うため
vicinity 店舗詳細のURLで使うため

店舗詳細と店舗案内、店舗写真のURLはこの後解説します。

ということで必要なデータのみを抜き出して配列を再生成しましょう。

api/src/Common/TemplateMessage/Gourmet/FormatGourmetArray.ts
// Load the module
import { getGourmetInfo } from './GetGourmetInfo';

// types
import { RequiredGourmetArray } from './types/FormatGourmetArray.type';

export const formatGourmetArray = async (
  user_id: string | undefined,
  googleMapApi: string
): Promise<RequiredGourmetArray> => {
  return new Promise(async (resolve, reject) => {
    // modules getGourmetInfo
    const gourmetInfo: any = await getGourmetInfo(user_id, googleMapApi);

    // Extract only the data you need
    const sufficientGourmetArray: any = gourmetInfo.filter(
      (gourmet: any) => gourmet.photos !== undefined || null
    );

    // Format the data as required
    const requiredGourmetArray: RequiredGourmetArray = sufficientGourmetArray.map(
      (gourmet: any) => {
        return {
          geometry_location_lat: gourmet.geometry.location.lat,
          geometry_location_lng: gourmet.geometry.location.lng,
          name: gourmet.name,
          photo_reference: gourmet.photos[0].photo_reference,
          rating: gourmet.rating,
          vicinity: gourmet.vicinity,
        };
      }
    );
    resolve(requiredGourmetArray);
  });
};

上記で、RequiredGourmetArrayという型を使用しているので型定義ファイルを作ります。

api/src/Common/TemplateMessage/Gourmet/types/FormatGourmetArray.type.ts
export type RequiredGourmetArray = {
  geometry_location_lat: number;
  geometry_location_lng: number;
  name: string;
  photo_reference: string;
  rating: number;
  vicinity: string;
}[];

⑧ 評価順に並び替えて上位10店舗にする

sortで並び替えて、sliceで新たな配列を作ってあげましょう!

api/src/Common/TemplateMessage/Gourmet/SortRatingGourmetArray.ts
// Load the module
import { formatGourmetArray } from './FormatGourmetArray';

// types
import { GourmetData, GourmetDataArray } from './types/SortRatingGourmetArray.type';

export const sortRatingGourmetArray = async (
  user_id: string | undefined,
  googleMapApi: string
): Promise<GourmetDataArray> => {
  return new Promise(async (resolve, reject) => {
    try {
      // modules formatGourmetArray
      const gourmetArray: GourmetDataArray = await formatGourmetArray(user_id, googleMapApi);

      // Sort by rating
      gourmetArray.sort((a: GourmetData, b: GourmetData) => b.rating - a.rating);

      // narrow it down to 10 stores.
      const sortGourmetArray: GourmetDataArray = gourmetArray.slice(0, 10);
      console.log(sortGourmetArray);
      resolve(sortGourmetArray);
    } catch (err) {
      reject(err);
    }
  });
};

型定義を行いましょう。

api/src/Common/TemplateMessage/Gourmet/types/SortRatingGourmetArray.type.ts
export type GourmetData = {
  geometry_location_lat: number;
  geometry_location_lng: number;
  name: string;
  photo_reference: string;
  rating: number;
  vicinity: string;
};

export type GourmetDataArray = GourmetData[];

⑨ Flex Messageを作成する

⑦で説明した必要なデータについて解説します。

必要なデータ 理由
geometry_location_lat 店舗案内のURLで使うため
geometry_location_lng 店舗案内のURLで使うため
name Flex Message内と店舗詳細のURLで使うため
photo_reference 店舗写真を生成するために使う
rating Flex Message内で使うため
vicinity 店舗詳細のURLで使うため

nameratingはFlex Message内で使います。

店舗詳細に関してですが、こちらのURLは以下となります。
https://maps.google.co.jp/maps?q=${店舗名}${住所}&z=15&iwloc=A

店舗案内に関しては以下のURLとなります。
https://www.google.com/maps/dir/?api=1&destination=${緯度},${経度}

店舗写真に関しては以下のURLとなります。
https://maps.googleapis.com/maps/api/place/photo?maxwidth=${任意の幅}&photoreference=${photo_reference}&key=${Google_API}

ということで、Flex Message内でこれらのURLを生成していけば完成です。
やっていきましょう!

api/src/Common/TemplateMessage/Gourmet/CreateFlexMessage.ts
// Load the package
import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk';

// Load the module
import { sortRatingGourmetArray } from './SortRatingGourmetArray';

// types
import { Gourmet, RatingGourmetArray } from './types/CreateFlexMessage.type';

export const createFlexMessage = async (
  user_id: string | undefined,
  googleMapApi: string
): Promise<FlexMessage | undefined> => {
  return new Promise(async (resolve, reject) => {
    try {
      // modules sortRatingGourmetArray
      const ratingGourmetArray: RatingGourmetArray = await sortRatingGourmetArray(
        user_id,
        googleMapApi
      );

      // FlexMessage
      const FlexMessageContents: FlexBubble[] = await ratingGourmetArray.map((gourmet: Gourmet) => {
        // Create a URL for a store photo
        const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${gourmet.photo_reference}&key=${googleMapApi}`;

        // Create a URL for the store details
        const encodeURI = encodeURIComponent(`${gourmet.name} ${gourmet.vicinity}`);
        const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`;

        // Create a URL for store routing information
        const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${gourmet.geometry_location_lat},${gourmet.geometry_location_lng}`;

        const flexBubble: FlexBubble = {
          type: 'bubble',
          hero: {
            type: 'image',
            url: photoURL,
            size: 'full',
            aspectMode: 'cover',
            aspectRatio: '20:13',
          },
          body: {
            type: 'box',
            layout: 'vertical',
            contents: [
              {
                type: 'text',
                text: gourmet.name,
                size: 'xl',
                weight: 'bold',
              },
              {
                type: 'box',
                layout: 'baseline',
                contents: [
                  {
                    type: 'icon',
                    url:
                      'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png',
                    size: 'sm',
                  },
                  {
                    type: 'text',
                    text: `${gourmet.rating}`,
                    size: 'sm',
                    margin: 'md',
                    color: '#999999',
                  },
                ],
                margin: 'md',
              },
            ],
          },
          footer: {
            type: 'box',
            layout: 'vertical',
            contents: [
              {
                type: 'button',
                action: {
                  type: 'uri',
                  label: '店舗詳細',
                  uri: storeDetailsURL,
                },
              },
              {
                type: 'button',
                action: {
                  type: 'uri',
                  label: '店舗案内',
                  uri: storeRoutingURL,
                },
              },
            ],
            spacing: 'sm',
          },
        };

        return flexBubble;
      });

      const flexContainer: FlexCarousel = {
        type: 'carousel',
        contents: FlexMessageContents,
      };

      const flexMessage: FlexMessage = {
        type: 'flex',
        altText: '近隣の美味しいお店10店ご紹介',
        contents: flexContainer,
      };

      resolve(flexMessage);
    } catch (err) {
      reject(err);
    }
  });
};

型定義を行いましょう。

api/src/Common/TemplateMessage/Gourmet/types/CreateFlexMessage.type.ts
export type Gourmet = {
  geometry_location_lat: number;
  geometry_location_lng: number;
  name: string;
  photo_reference: string;
  rating: number;
  vicinity: string;
};

export type RatingGourmetArray = Gourmet[];

⑩ お店の情報をFlex Messageで送る

api/src/index.ts
// Load the package
import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk';
import aws from 'aws-sdk';

// Load the module
// TemplateMessage
import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation';
import { errorTemplate } from './Common/TemplateMessage/Error';
import { isCarTemplate } from './Common/TemplateMessage/IsCar';
+ import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage';
// Database
import { putLocation } from './Common/Database/PutLocation';
import { updateIsCar } from './Common/Database/UpdateIsCar';

// SSM
const ssm = new aws.SSM();
const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
  Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
  WithDecryption: false,
};
const LINE_GOURMET_CHANNEL_SECRET = {
  Name: 'LINE_GOURMET_CHANNEL_SECRET',
  WithDecryption: false,
};
const LINE_GOURMET_GOOGLE_MAP_API = {
  Name: 'LINE_GOURMET_GOOGLE_MAP_API',
  WithDecryption: false,
};

exports.handler = async (event: any, context: any) => {
  // Retrieving values in the SSM parameter store
  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();
  const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise();
  const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  const channelSecret: string = CHANNEL_SECRET.Parameter.Value;
  const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value;

  // Create a client using the SSM parameter store
  const clientConfig: ClientConfig = {
    channelAccessToken: channelAccessToken,
    channelSecret: channelSecret,
  };
  const client = new Client(clientConfig);

  // body
  const body: any = JSON.parse(event.body);
  const response: WebhookEvent = body.events[0];

  // action
  try {
    await actionLocationOrError(client, response);
    await actionIsCar(client, response);
    await actionFlexMessage(client, response, googleMapApi);
  } catch (err) {
    console.log(err);
  }
};

const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const text = event.message.text;

    // modules
    const yourLocation = await yourLocationTemplate();
    const error = await errorTemplate();

    // Perform a conditional branch
    if (text === 'お店を探す') {
      await client.replyMessage(replyToken, yourLocation);
    } else if (text === '' || text === '徒歩') {
      return;
    } else {
      await client.replyMessage(replyToken, error);
    }
  } catch (err) {
    console.log(err);
  }
};

const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'location') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const latitude: string = String(event.message.latitude);
    const longitude: string = String(event.message.longitude);

    // Register userId, latitude, and longitude in DynamoDB
    await putLocation(userId, latitude, longitude);

    // modules
    const isCar = await isCarTemplate();

    // Send a two-choice question
    await client.replyMessage(replyToken, isCar);
  } catch (err) {
    console.log(err);
  }
};

const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const isCar = event.message.text;

    // Perform a conditional branch
    if (isCar === '' || isCar === '徒歩') {
      // Register userId, isCar in DynamoDB
      await updateIsCar(userId, isCar);
+     const flexMessage = await createFlexMessage(userId, googleMapApi);
+     if (flexMessage === undefined) {
+       return;
+     }
+     await client.replyMessage(replyToken, flexMessage);
    } else {
      return;
    }
  } catch (err) {
    console.log(err);
  }
};

デプロイ

まずは、npm run buildでコンパイルしましょう。

ターミナル
$ npm run build

コンパイルされた後は、ビルドしてデプロイしていきましょう。

ターミナル
// ビルド
$ sam build

// デプロイ
$ sam deploy --guided

DynamoDBも確認しましょう。

スクリーンショット 2021-08-06 10.43.48.png

しっかり保存されていますね!

最後に

追加する要件として、今後はお気に入りのお店を登録する機能なども足していこうと思います。

ここまで読んでいただきありがとうございました!

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?