LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。
完成形としては以下の通りです。
アーキテクチャ
今回はサーバーレスアーキテクチャの根幹の機能である、「AWS Lambda」, 「Amazon API Gateway」, **「Amazon DynamoDB」**の3つを使用してアプリを作成します。
また、プロビジョニングやデプロイに関しては**AWS SAM(Serverless Application Model)**を使用します。
対象読者
・ Node.jsを勉強中の方
・ TypeScriptを勉強中の方
・ インフラ初心者の方
・ ポートフォリオのデプロイ先をどうするか迷っている方
作成の難易度は低めです。
理由は、必要なパッケージも少ないため要件が複雑ではないからです。
また、DynamoDBの操作は、CRUD(作成・読み込み・更新・削除)のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。
記事
今回は2つの記事に分かれています。
お店の検索を行うところまでを今回の記事で行っています。
お気に入り店の登録や解除などを次の記事で行います。
どのようなアプリか
皆さんは、どのようにして飲食店を探しますか?
私は、食べログなどのグルメサイトを使わずに Google Mapで探します。
以前食べログで「星 3.8 問題」がありました。
これだけではなく、食べログで見つけた行ったお店がイマイチだったこともあり、
グルメサイトはお店を探す場所ではなく、お店を予約するためのサイトと私は割り切りました。
電話が苦手な自分としては、まだまだ飲食店で独自の予約サイトを持っている企業も少ないので、食べログやホットペッパーで予約が可能なのはすごく助かっています。
Google Mapでお店を探すのもなかなか手間がかかるので、今回はGoogle Mapを使って近くの名店を10個教えてくれるアプリを作成しました。
Github
完成形のコードは以下となります。
アプリQR
こちらから触ってみてください。
アプリの流れ
クライアント | 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の実行をしてみたいところですが、一旦後回しにします。
先に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
でコンパイルを行います。
{
"scripts": {
"clean": "rimraf dist",
"tsc": "tsc",
"build": "npm-run-all clean tsc"
},
}
tsconfig.json
の作成
以下のコマンドを実行しTypeScriptの初期設定を行います。
$ npx tsc --init
それでは、作成された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
も作成しておきましょう。
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を記載する
ファイル内にコメントを残しています。
これで大まかには理解できるかと思います。
詳しくは公式サイトを見てください。
# 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
となるので、チャネルの種類を間違えた方は修正してください。
チャネルシークレットとチャネルアクセストークンが必要になるのでこの2つを発行します。
これで必要な環境変数は取得できました。
それでは、これをSSMを使ってLambda内で使えるようにしていきましょう。
SSMパラメータストアで環境変数を設定
なぜSSMパラメータストアを使うのか?
SAMのLambda設定にも、環境変数の項目はあります。
しかし、2点問題点があります。
①Lambdaの環境変数の変更をしたいとき、Lambdaのバージョンも新規発行をしなければならない
②Lambdaのバージョンとエイリアスを紐付けて管理をするとき、もし環境変数にリリース先環境別の値をセットしていると、リリース時に手動で環境変数の変更をしなければならないケースが発生する
簡単にまとめると、**「リアルタイムで反映できないし、人為的なミスのリスクもあるよ」**ということです。
SSMパラメータストアで値を管理すると以下の3点のメリットがあります。
①Lambdaの環境変数の管理が不要
②Lambdaも含めた値関連情報を一元管理できる
③Lambda外部からリアルタイムに環境変数を変更制御できる
ということで、SSMパラメータストアを使用しましょう。
みんな大好きクラスメソッドの記事にやり方が書いてあります。
こちらの記事が完璧なのでこちらを見てやってみてください。
私は以下のように命名して作成しました。
SSMパラメータが取得できているかconsole.log
で検証
// 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を確認しましょう。
Webhook URLの登録
先ほどAPI Gateway
で作成したhttpsのURLをコピーしてください。
これをLINE DevelopersのWebhookに設定します。
それではSSMパラメータが正しく取得できているか確認しましょう。
CloudWatchで確認しましょう!
取得できていますね!
これで準備は完了です。
ここから飲食店検索の仕組みを作っていきましょう!
アプリの流れ
クライアント | LINE Messaging API(バックエンド) |
---|---|
①「お店を探す」をタップ | |
②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 | |
③ 現在地を送る | |
④「車か徒歩どちらですか?」というメッセージを送る | |
⑤ 車か徒歩を選択 | |
⑥ お店の配列を作成する(車の場合現在地から 14km 以内、徒歩の場合 0.8km 以内) | |
⑦ 必要なデータのみにする | |
⑧ 評価順に並び替えて上位 10 店舗にする | |
⑨ Flex Message を作成する | |
⑩ お店の情報を Flex Message で送る |
①「お店を探す」をタップ
こちらに関してはクライアント側の操作なので作業することはありません。
②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信
「現在地を送る」ためのボタンメッセージ
// 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/
詳しくは以下をご確認ください。
エラーメッセージ
// 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);
});
};
メッセージの送信
// 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の記載を行う
# 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
を使用します。
// 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
で読み込みましょう。
// 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への登録が完了です。
次にメッセージを作成しましょう。
// 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
に読み込みましょう。
// 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
を使用します。
ではやっていきましょう。
// 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
で読み込みます。
// 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のデータを取得する
// 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のコンソール画面に入って下さい。
コンソールに入ったらプロジェクトを作成しましょう!
私は、LINE-Node-TypeScript-Gourmet
で作成しました。
では、ライブラリを有効化しましょう!
使うライブラリは2つです。
Map JavaScript API
Places API
お店検索をするAPIは「Places API」ですが、
JavaScriptから呼び出すために「Map JavaScript API」が必要となります。
ここまでできたら次にAPIを作成しましょう。
これからの開発はこちらのAPIキーを使います。
セキュリティ的には制限をつけたほうがいいのですが、今回はつけずに行います。
上記の説明でわからなければ以下のサイトを参考にされて下さい。
では取得したAPIをSSMパラメーターストアに登録しましょう。
方法は以下の通りです。
私はこのように命名しました。
ではこの値を関数内で使えるようにしましょう。
// 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 SearchのNearby Search requestsを使います。
ここが正直イマイチなコードかもしれません。
setTimeout
を頻発しているからです。
Nearby Search requestsは20店舗しか取り出すことができないのですが、
pagetoken
を使用することで60店舗取り出すことができます。
このpagetoken
を使って再度呼び出しを行うのですが、その時に待ち時間が必要になります。
最初は、async, await
の非同期で対応できると思っていたのですが、この待ち時間だけでは足りないようでsetTimeout
が必要になりました。
こちらはコードがイマイチなので、対応を考えて他の方法があれば修正いたします。
ここはこんなコードの書き方もあるんだ程度にしていただけますと幸いです。
// 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はこの後解説します。
ということで必要なデータのみを抜き出して配列を再生成しましょう。
// 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
という型を使用しているので型定義ファイルを作ります。
export type RequiredGourmetArray = {
geometry_location_lat: number;
geometry_location_lng: number;
name: string;
photo_reference: string;
rating: number;
vicinity: string;
}[];
⑧ 評価順に並び替えて上位10店舗にする
sort
で並び替えて、slice
で新たな配列を作ってあげましょう!
// 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);
}
});
};
型定義を行いましょう。
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で使うため |
name
とrating
は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を生成していけば完成です。
やっていきましょう!
// 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);
}
});
};
型定義を行いましょう。
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で送る
// 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も確認しましょう。
しっかり保存されていますね!
最後に
追加する要件として、今後はお気に入りのお店を登録する機能なども足していこうと思います。
ここまで読んでいただきありがとうございました!