- 投稿日:2021-08-09T18:12:23+09:00
文字コードを指定してURLエンコードを行う(Node.js)
文字コードを指定してURLエンコードしたい。日本語(が含まれるん)だもの。 そういう話です。 encodeURI()はUTF-8を表すエスケープシーケンスで置換される Node.jsを用いて、あるAPIをGETメソッドでリクエストする処理を実装していました。 そのAPIはクエリストリングを APIの名前?key1=value1&key2=value2&signature=認証用の値 にしてリクエスト送信してくださいね、ということで下記のようなソースでクエリストリングを作成。 // 例えばユーザーの情報を取得するAPI // API名称 const apiName = 'getUser'; // リクエスト内容 const params = { name : '則巻アラレ', age : 13 } /** * クエリストリングを作成する * @param {String} apiName API名称 * @param {Object} params リクエスト内容 * @returns クエリストリング */ function makeQueryString(apiName, params) { let queryString = `${apiName}?`; // リクエスト内容分ループ for (const key in params) { const value = params[key]; // 値 queryString += `${key}=${value}&`; } // 末尾に認証用の値を付与 queryString += `signature=xxx`; // URLエンコード return encodeURI(queryString); } // クエリストリングの作成 const queryString = makeQueryString(apiName, params); しかし、encodeURI()1を用いてURLエンコードを行ったクエリストリングを使って、GETリクエストを送信しても、該当ユーザーがいない、というレスポンスばかりが返ってきていました 認証エラーが返ってきていないので、認証は通っている...。 何がダメなんだ?とそのAPIの開発者向けドキュメントを再読すると、以下の記述。 「Windows-31JでURLエンコードを行ってください」 文字コード CP932 / Windows-31J CP932とは、日本語の文字などを収録した文字コード規格の一つで、Shift JIS規格を元にマイクロソフト(Microsoft)社が独自に拡張したもの。微妙に異なる複数の仕様がある。 〔......〕同社(注: Microsoft社)はCP932のインターネット上での識別名としてIANAに「Windows-31J」を登録し〔......〕 CP932とは - IT用語辞典 ぶっちゃけこの文字コード初めて聞いたな...と思いつつ、 encodeURI()について確認すると、確かにrepresenting the UTF-8 encoding of the character2と記載があるので、UTF-8の文字を表すエスケープシーケンスで置換されるようです。 先ほどのコードだと、以下クエリストリングは getUser?name=則巻アラレ&age=13&signature=xxx encodeURI()によって、以下に変換されます。 name部分は、UTF-8の文字を表す(らしい)エスケープシーケンスで置換されています。 getUser?name=%E5%89%87%E5%B7%BB%E3%82%A2%E3%83%A9%E3%83%AC&age=13&signature=xxx 今回は、URLエンコードが行われた文字列を、Windows-31Jの文字列として表す必要があるので、標準の関数は使えないですね 文字コードを指定してURLエンコードができるiconv-urlencode やっと本題。 UTF-8以外の文字コードを指定してURLエンコードを行う必要があるなら、 iconv-urlencodeというパッケージを用いることで、それが可能です。 上記サイトの説明によると、iconv-liteパッケージで指定可能な文字コード3であれば、URLエンコード/デコード可能なようです。日本語ではShift_JIS, Windows-31j等が使用可能です。 以下の実行環境で実施していきます。 $ node --version v14.17.4 $ npm --version 6.14.14 インストール。 npm install iconv-urlencode あとは先ほどのソースコードに、モジュールの読み込みを追加し、 関数内で作成したクエリストリングをURLエンコードして返すようにします。 const conv = require('iconv-urlencode'); const encoding = 'Windows-31j'; // 文字コード // ...... // 略 // ...... function makeQueryString(apiName, params) { let queryString = `${apiName}?`; // リクエスト内容分ループ for (const key in params) { const value = params[key]; // 値 queryString += `${key}=${value}&`; } // 末尾に認証用の値を付与 queryString += `signature=xxx`; // 文字コードを指定し、URLエンコード return conv.encode(queryString, encoding); // ★iconv-urlencodeを利用 } ...と上記ソースコードだと実行結果がこうなります。 # getUser?name=則巻アラレ&age=13&signature=xxx のURLエンコード結果 getUser%3Fname%3D%91%A5%8A%AA%83A%83%89%83%8C%26age%3D13%26signature%3Dxxx 今回は?, =, &は変換されてほしくないので、修正。 値のみURLエンコードするようにします。 function makeQueryString(apiName, params) { let queryString = `${apiName}?`; // リクエスト内容分ループ for (const key in params) { // ★値のみURLエンコードを行う(文字コードを指定) const value = conv.encode(params[key], encoding); queryString += `${key}=${value}&`; } // 末尾に認証用の値を付与 queryString += `signature=xxx`; return queryString; } 結果は以下のようになりました。 # getUser?name=則巻アラレ&age=13&signature=xxx のURLエンコード結果(Windows-31J) getUser?name=%91%A5%8A%AA%83A%83%89%83%8C&age=13&signature=xxx これで無事に文字コードWindows-31Jの文字列として、サーバ側でデコードができて、 指定ユーザーの情報がAPIのレスポンスで返ってくるようになりましたとさ、めでたしめでたs ......残念ですが、このままではある条件下でエラーになる場合があります。 値だけURLエンコードをしていると、数字の0が消える それは、以下のユーザーで検索を行ったときに起きました...。 // リクエスト内容 const params = { name : '則巻ターボ', age : 0 } # 作成されたクエリストリング getUser?name=%91%A5%8A%AA%83%5E%81%5B%83%7B&age=&signature=xxx おわかりいただけただろうか...。 そう、ageの値が欠けているのだ...。 ageの値を文字列の'0'とした場合はクエリストリングにage=0&...となりますが、 数字の0であった場合は、age=&...として値が欠けてしまいます。 今回は値が数字の0であった場合は、URLエンコードしないようにしました。 const value = params[key] === 0 ? params[key] : conv.encode(params[key], encoding); # getUser?name=則巻ターボ&age=0&signature=xxx のURLエンコード結果(Windows-31J) getUser?name=%91%A5%8A%AA%83%5E%81%5B%83%7B&age=0&signature=xxx ひとまず、(今回は)ヨシ! URLエンコードのまとめ JavaScriptのencodeURI()/encodeURIComponent()関数はUTF-8の文字を表すエスケープシーケンスで置換される UTF-8以外の文字コードを指定する場合は、iconv-urlencodeパッケージを使うと可能 数字の0やfalseはURLエンコード時に消えてしまうので、それらはエンコードしないようにする というわけで、文字コードを指定して、URLエンコードを行う方法でした。 (今回は本筋から逸れるので記載していませんが、実際は入力値のバリデーションチェックもしています) 利用するAPIの仕様から「=」と「&」をエンコードさせないためにencodeURI()を使用しています。encodeURIComponent()との違いはMDN Web Docsに例として記載があります ↩ 翻訳が若干分かりにくかったので、英語版から引用。ちなみにencodeURIComponent()も同様にUTF-8の文字列として表される。 ↩ iconv-liteのgithub内のwikiにサポートしている文字コードの一覧があります。Windows-31jが!指定!できる! ↩
- 投稿日:2021-08-09T17:41:06+09:00
【初心者向け】結局package.jsonって何なの?
概要 難しい活字は読む気が起きずこれまで目をそらしていたpackage.jsonだが,いつまでもこのままではいけないのでしっかり理解してみようと思い,重い腰をあげて色々と調べてみた.自分と同じような駆け出しエンジニアの方たち向けに,自分が調べたことの共有ができればと思う. 何かご指摘等ありましたらコメントをいただけますと幸いです. はじめに package.jsonがなんたるかを理解するうえでNode.jsとnpmの理解は必須であり,本記事もこの2つの知識を前提に書いていく.この2つをしっかり理解した上で読んで欲しい.分量が多くなりそうなので自分は書かない. 本記事ではpackage.jsonとはそもそも何なのか,どういった役割があるのか,どうやって作成するかの3点について書く.近いうちにpackage.jsonの中身については別途詳しくまとめる. package.jsonとは package.jsonを一言で表すならば,「アプリ開発の際に自分がインストールして使ったライブラリと同じライブラリを,他の開発者の人にも使ってもらうための情報が詰まったファイル」と自分は表したい(違和感を感じた先輩エンジニアの方がいらっしゃいましたらコメントください). ここでいう情報とは,例えば使用したライブラリやそのバージョン,ライブラリの依存関係などである.依存関係は少し専門的な用語になるが,イメージとしては「餃子をつくるためには皮と中身が必要で,中身をつくるにはさらに野菜と肉が必要で...」というように,何かの実行に別の何かが依存している,そんな構造だと思ってもらえると良い.「あるライブラリを動かすためには別のライブラリが必要で,そのライブラリにはまた別のが必要で...」といった感じ. package.jsonの恩恵 package.jsonの正体を書いたところで,「じゃああるとなんで良いの?」みたいな話をしていこうと思う.結論から言うと,npm installコマンドを使ってライブラリのインストールを行うことができることだ.これについて,少し遠回りだが噛み砕いた説明を試みる. 複数人で共同開発を行う際,使用したライブラリやそのバージョンなどが同じ環境で開発を行うことが望ましい.ライブラリが同じである必要性は自明だろうが,同じバージョンを使用する理由は,バージョンが変わるとコードの書き方などが変わる可能性があるからだ.これにより,他の人の開発時には動いていたコードが最新版になると動かなくなる,といったことが起こる可能性がある1. 同じバージョンの同じライブラリを自分の開発環境で復元する必要があることがおわかりいただけたと思うが,他人が使ったライブラリ全てをバージョンまでそろえて自分の手で探してきて自分の環境に復元する(インストールする)のは時間も手間もかかるのであまり現実的でない.さらには先述したような依存関係もあるので尚更だ. ここで,npm installコマンドが真価を発揮する.たった1行,このコマンドを叩くだけでpackage.jsonファイル内の情報を参照して,使用ライブラリやバージョン,依存関係まで全く同じものを一括でインストールしてくれる.だから,package.jsonは「お便利情報が詰まったファイル」だと自分は思う. package.jsonの作成方法 コマンドを1行実行するだけ.超簡単. $ npm init これだけでpackage.jsonファイルは作成できる.ちなみに驚くほど余談だが,自分は素直な少年なので初めてコマンドを操作した時は$マークまで含めてコマンドに打ち込んだのだが,当然エラーが起こって憤慨した.この行はコマンドで実行する操作ですよっていうのを表すために慣習として$マークを使っているだけらしく,全く必要ないので自分の二の舞にならないで欲しい. こんな話はさておき,上記コマンドを実行すると何やら小難しい英文がズラズラと出てくるが,package.jsonファイル内に書く情報の設定を行っているだけなので,作成したプロジェクトをパッケージとして公開するつもりが無ければ特に何もせずEnterキーを押し続けてもらって問題ない.そして最終的に以下のような画面になれば完了.Is this OK? 当然OKなので自信満々にEnterキーを押そう. This utility will walk you through creating a package.json file. It only covers the most common items, and tries to guess sensible defaults. See `npm help init` for definitive documentation on these fields and exactly what they do. Use `npm install <pkg>` afterwards to install a package and save it as a dependency in the package.json file. Press ^C at any time to quit. package name: (xxxxxx) version: (1.0.0) description: entry point: (index.js) test command: git repository: keywords: author: license: (ISC) About to write to C:¥Uses¥User¥Desktop¥test.qiita¥package.json: { "name": "test.qiita", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo ¥"Error: no test specified¥" && exit 1" }, "author": "", "license": "ISC" } Is this OK? (yes) 実際に他のコマンドとあわせて使ってみると,以下の感じ. $ mkdir xxxxx # xxxxxというディレクトリ(フォルダ)を作成 $ cd xxxxx # 作成したディレクトリ(フォルダ)へ移動 $ npm init -y # デフォルトの値でpackage.jsonを作成 $ npm install パッケージ名 # パッケージのインストール -yはオプションと呼ばれるもので,これを付けることで実行したいコマンドに追加的な指示を与えることができる.今回の場合はデフォルトの値でという指示を追加した.このオプションによってEnterキーを連打した時と全く同じpackage.jsonファイルを作成できる.もちろん,お好みで連打の方を選んでもらっても構わない. まとめ 本記事執筆のおかげで今まで理解しようと努めてこなかったpackage.jsonについて知る良い機会になったので良かった.周辺知識も少し増えたので,別の機会に書いてみたいと思う.これを読んでくれた駆け出しエンジニアの同士の皆さんがpackage.jsonについて少しでも理解が深まっていたら非常に嬉しい. 公式サイト NPM package.json(英語版) NPM package.json(日本語版) おススメ記事 今回の執筆にあたって拝読した記事の内,特にわかりやすくて他の人にもぜひ読んでいただきたいものをここに載せる. 【初心者向け】NPMとpackage.jsonを概念的に理解する 初学者向けpackage.jsonハンズオン ちなみに,こうしたことを起こさないためにpackage.jsonファイルに加えてpackage-lock.jsonファイルというのも作成される.また話が複雑になるので,本記事では割愛する. ↩
- 投稿日:2021-08-09T16:52:12+09:00
AtCoder Beginner Contest 213 ~JavaScriptの練習~
感想 リアタイで参加したが、実装ミスなどにより最悪な結果となった。 D問題までは難しくなかったが、E問題が大きな壁になった。 E問題のように、操作をコストと捉えてダイクストラや01BFSに落とすのは以前に見たことがあったが、実戦から離れすぎて忘れてしまった。 再び精進する気があまりないので、Kaggleなどの別の競技をやってみるのが良いのかもしれない。 A問題 考察 $A \oplus C=B \leftrightarrow C=A \oplus B$なので、xorを計算するのみ。 文法 入力の部分が面倒なので、簡素化した方が良いかもしれない。 それ以外は特に感じたことはなかった。 コード // inputに入力データ全体が入る function Main(input) { // 1行目がinput[0], 2行目がinput[1], …に入る input = input.split("\n"); let a,b;[a,b]=input[0].split(" ").map((x)=>{return parseInt(x,10)}); console.log(a^b) } //標準入出力から一度に読み込み、Mainを呼び出す Main(require("fs").readFileSync("/dev/stdin", "utf8")); 簡素ver function toInt(x){ return parseInt(x,10); } // inputに入力データ全体が入る function Main(input) { // 1行目がinput[0], 2行目がinput[1], …に入る input = input.split("\n"); let a,b;[a,b]=input[0].split(" ").map(toInt); console.log(a^b) } //標準入出力から一度に読み込み、Mainを呼び出す Main(require("fs").readFileSync("/dev/stdin", "utf8")); B問題 考察 スコアの降順に並べて2番目の数の番号を出力すれば良い。 出力するのは番号なので、スコアと番号を組にして持っておく。 文法 クラス 組にして持つために今回はクラスを使った。クラスについてはMDNのドキュメントに詳しいので、参考にされたい。 今回はクラス宣言を用いてクラスを定義した。Pythonでいうメンバ変数をプロパティと呼び、クラス内ではthis.プロパティ名によりアクセス/定義できる。クラス外ではインスタンス名.プロパティ名でアクセスできる。(通常の定義の場合はPrivateになる) ソート ソートがかなり癖がある。詳しくはMDNのドキュメントを参考にされたい。 まず、デフォルトの比較関数が、各要素を文字列に変換した際のUnicodeでのポイント順という仕様になっている。また、比較関数を自分で指定する時をここでは考える。 比較関数をcompareFunction(a,b)としたとき compareFunction(a,b)が0未満の時 aをbより小さいインデックスにソートする compareFunction(a,b)が0の時 aとbの位置を変えない(環境によっては保証されない) compareFunction(a,b)が0より大きい時 aをbより大きいインデックスにソートする コード class player{ constructor(ind,ban){ this.ind=ind; this.ban=ban; } } // inputに入力データ全体が入る function Main(input) { // 1行目がinput[0], 2行目がinput[1], …に入る let n,a,b; [n,b]=input.split("\n"); n=parseInt(n,10); a=[]; b=b.split(" "); for(let i=0;i<n;i++){ a.push(new player(i,parseInt(b[i],10))); } a.sort((i,j)=>{return j.ban-i.ban;}); console.log(a[1].ind+1); } //標準入出力から一度に読み込み、Mainを呼び出す Main(require("fs").readFileSync("/dev/stdin", "utf8")); C問題 考察 少なくとも一つのカードを含む行および列が残るので、座標圧縮を行えば良い。 出てくる行について重複がないように昇順に並べ、出てくる列について重複がないように昇順に並べることで座標圧縮を行うことができる。 最終的に求めるのは、それぞれのカードがどの座標へと移るかなので、出力の前にnowr[元の行]=移る先の行とnowc[元の列]=移る先の列を用意した。 文法 Set 重複を除くためにSetを利用した。new演算子によりインスタンスを生成でき、追加する際はaddメソッドを用いる。詳しくはMDNのドキュメントを参考にする。 オブジェクト(連想配列) オブジェクトに対してドット表記か角括弧表記のいずれかでアクセスすることができる。 ドット表記の場合はプロパティであることを意識してアクセスする。また、プロパティは文字列と結びついているので、角括弧表記を用いて連想配列としてアクセすることができる。この時、プロパティ名は文字列に変換できるものであれば使用することができる。今回の場合は整数を用いている。 ただ、連想配列としてのみの利用の場合はMapオブジェクトを用いた方が良いと思われる(参考)。 コード function compareNumbers(a,b){ return a - b; } // inputに入力データ全体が入る function Main(input) { // 1行目がinput[0], 2行目がinput[1], …に入る input = input.split("\n"); let h,w,n; [h,w,n]=input[0].split(" ").map((x)=>parseInt(x)); // r:行、c:列、候補 let r=new Set(); let c=new Set(); let card=[]; for(let i=1;i<=n;i++){ card.push(input[i].split(" ").map((x)=>parseInt(x))); r.add(card[i-1][0]); c.add(card[i-1][1]); } let nowr,nowc; [nowr,nowc]=[{},{}]; r=Array.from(r); r.sort(compareNumbers); c=Array.from(c); c.sort(compareNumbers); for(let i=0;i<r.length;i++){ nowr[r[i]]=i+1; } for(let i=0;i<c.length;i++){ nowc[c[i]]=i+1; } for(let i=0;i<n;i++){ console.log(nowr[card[i][0]],nowc[card[i][1]]); } } //標準入出力から一度に読み込み、Mainを呼び出す Main(require("fs").readFileSync("/dev/stdin", "utf8"));
- 投稿日:2021-08-09T15:44:56+09:00
【個人開発/LINE Messaging API】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(②)
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つの記事に分かれています。 お気に入り店の登録や解除などを今回の記事で行っています。 お店の検索を行うところまでを前の記事で行っています。 前回の記事をやっていないと今回の記事は全く理解できないのでこちらからご確認ください。 Github 完成形のコードは以下となります。 アプリQR こちらから触ってみてください。 アプリの機能 今回は3つの機能を足していきます。 お気に入り登録 クライアント LINE Messaging API(バックエンド) ①メッセージを編集し、「行きつけ」ボタンを追加する ②「行きつけ」をタップする ③DynamoDBを作成する ④ポストバックのデータを元にDynamoDBに登録を行う お気に入り店を探す クライアント LINE Messaging API(バックエンド) ①「行きつけ」をタップする ②user_idを元にDynamoDB から検索を行う ③FlexMessageを作成する ④お店の情報をFlexMessageで送る お気に入り店の解除 クライアント LINE Messaging API(バックエンド) ①「行きつけを解除」をタップする ②user_idとtimestampを元にDynamoDBからデータを削除する ハンズオン! お気に入り登録を行う 機能 これだけじゃイメージがつきにくいと思うので完成図を先に見せます。 ①メッセージを編集し、「行きつけ」ボタンを追加する 「行きつけ」をタップすることで、お店の情報を渡したいのでポストバックアクションを使用します。 こちらを使うことで、dataプロパティの値を受け取ることができます。 普通にメッセージとして送ってもいいのですが、送られる返信がお店の情報になってしまいます。 あまりよろしくないので、採用を見送りました。 ということでやっていきましょう。 今回は前回の記事で作成したCreateFlexMessage.tsに追加していきます。 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, }, }, + { + type: 'button', + action: { + type: 'postback', + label: '行きつけ', + data: `lat=${gourmet.geometry_location_lat}&lng=${gourmet.geometry_location_lng}&name=${gourmet.name}&photo=${gourmet.photo_reference}&rating=${gourmet.rating}&vicinity=${gourmet.vicinity}`, + displayText: '行きつけにする', + }, + }, ], 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); } }); }; ②「行きつけ」をタップする こちらはクライアント側での操作なのでやることはありません。 ③DynamoDBを作成する 前回の記事では、SAMテンプレートでDynamoDBを作成したのですが、今回は手動で作成します。 DBは以下のような値を持つレコードを作成していきます。 PK SK K K K K K user_id timestamp photo_url name rating store_details_url store_routing_url ユーザー ID タイムスタンプ 店舗の写真 店舗の名前 店舗の評価 店舗詳細 店舗案内 ソートキーを使う場合どのようにSAMを使うのかの記載が見つからなかったので手動とします。(SAMテンプレートでのやり方を知っている方がいましたらお教えいただけますと幸いです。) DynamoDBを作ったことがない人もいると思うので、一応画像で説明します。 名前は何でもいいです。 一応自分は、Gourmets_Favoriteで作成しています。 先に作成しているのでエラーメッセージ出てますが気にしないでください。 ④ポストバックのデータを元にDynamoDBに登録を行う まずは関数を呼び出している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'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; + import { putFavorite } from './Common/Database/PutFavorite'; // 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); + await actionPutFavoriteShop(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 if (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); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る 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); } }; + // FlexMessageの「行きつけ」をタップしたらそのお店が登録される + const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: + string) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const timestamp = event.timestamp; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite === -1) { + // Register data, userId in DynamoDB + await putFavorite(data, timestamp, userId, googleMapApi); + } + } catch (err) { + console.log(err); + } + }; では、DynamoDBにデータを追加するコードを書いていきましょう。 データの追加はputを使用します。 また、次にポストバックのデータの使用方法に関してです。 { "type":"postback", "label":"Buy", + "data":"action=buy&itemid=111", "text":"Buy" } データはこのように渡されます。 この値をどのように取得するかお分かりでしょうか? JavaScriptに慣れている方であればすぐにお分かりでしょうね! 指定した区切り文字で分割して文字列の配列にしましょう。 ということで使うのは、splitですね。 ということでやっていきましょう。 api/src/Common/Database/PutFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const putFavorite = ( data: string, timestamp: number, userId: string | undefined, googleMapApi: string ) => { return new Promise((resolve, reject) => { // data const dataArray = data.split('&'); const lat = dataArray[0].split('=')[1]; const lng = dataArray[1].split('=')[1]; const name = dataArray[2].split('=')[1]; const photo = dataArray[3].split('=')[1]; const rating = dataArray[4].split('=')[1]; const vicinity = dataArray[5].split('=')[1]; // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${photo}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${name} ${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=${lat},${lng}`; const params = { Item: { user_id: userId, timestamp: timestamp, photo_url: photoURL, name: name, rating: rating, store_details_url: storeDetailsURL, store_routing_url: storeRoutingURL, }, ReturnConsumedCapacity: 'TOTAL', TableName: 'Gourmets_Favorite', }; docClient.put(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; これで完了です。 それでは次に、お気に入りのお店を探しましょう。 お気に入り店を探す 機能 こちらも先にどのような機能かお見せします。 「行きつけ」をタップしたら、お気に入り登録したお店の一覧が表示されます。 ①「行きつけ」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idを元にDynamoDBから検索を行う DynamoDBからお気に入りのお店の情報を取得しましょう。 今回は複数取得する可能性が高いのでqueryを使用します。 ということでやっていきましょう。 api/src/Common/TemplateMessage/Favorite/QueryDatabaseInfo.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const queryDatabaseInfo = async (userId: string | undefined) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets_Favorite', ExpressionAttributeNames: { '#u': 'user_id' }, ExpressionAttributeValues: { ':val': userId }, KeyConditionExpression: '#u = :val', }; docClient.query(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ③FlexMessageを作成する DynamoDBから取得した値を使用してFlexMessageを作成していきましょう。 api/src/Common/TemplateMessage/Favorite/MakeFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { queryDatabaseInfo } from './QueryDatabaseInfo'; // types import { Item, QueryItem } from './types/MakeFlexMessage.type'; export const makeFlexMessage = async (userId: string | undefined): Promise<FlexMessage> => { return new Promise(async (resolve, reject) => { try { // modules queryDatabaseInfo const query: any = await queryDatabaseInfo(userId); const queryItem: QueryItem = query.Items; // FlexMessage const FlexMessageContents: FlexBubble[] = await queryItem.map((item: Item) => { const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: item.photo_url, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: item.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: `${item.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: item.store_details_url, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: item.store_routing_url, }, }, { type: 'button', action: { type: 'postback', label: '行きつけを解除', data: `timestamp=${item.timestamp}`, displayText: '行きつけを解除する', }, }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: 'お気に入りのお店', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; 独自の型定義があるのでファイルを作成しましょう。 api/src/Common/TemplateMessage/Favorite/types/MakeFlexMessage.type.ts export type Item = { user_id: string; photo_url: string; rating: string; timestamp: number; name: string; store_routing_url: string; store_details_url: string; }; export type QueryItem = Item[]; ④お店の情報をFlexMessageで送る 最後にFlexMessageで送信しましょう。 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'; + import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; // 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]; // console.log(JSON.stringify(response)); // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); await actionPutFavoriteShop(response, googleMapApi); + await actionTapFavoriteShop(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 if (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); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る 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); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; + // リッチメニューの「行きつけ」をタップしたらメッセージが送られる + const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { + // 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 text = event.message.text; + + if (text === '行きつけのお店') { + const flexMessage = await makeFlexMessage(userId); + if (flexMessage === undefined) { + return; + } + await client.replyMessage(replyToken, flexMessage); + } else { + return; + } + }; お気に入り店の解除 機能 「行きつけを解除」をタップするとデータが消去されます。 ①「行きつけを解除」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idとtimestampを元にDynamoDBからデータを削除する こちらも同様にポストバックを使用します。 { "type": "postback", "label": "行きつけを解除", "data": `timestamp=${item.timestamp}`, "displayText": "行きつけを解除する", } こちらもsplitを使って値を取得しましょう。 次にDynamoDBの削除は、deleteを使用します。 api/src/Common/Database/DeleteFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const deleteFavorite = (data: string, userId: string | undefined) => { return new Promise((resolve, reject) => { // data const timestamp: number = Number(data.split('=')[1]); const params = { TableName: 'Gourmets_Favorite', Key: { user_id: userId, timestamp: timestamp, }, }; docClient.delete(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ではこの関数を読み込みましょう。 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'; import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; + import { deleteFavorite } from './Common/Database/DeleteFavorite'; // 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); await actionPutFavoriteShop(response, googleMapApi); await actionTapFavoriteShop(client, response); + await actionDeleteFavoriteShop(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 if (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); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る 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); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; // リッチメニューの「行きつけ」をタップしたらメッセージが送られる const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { // 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 text = event.message.text; if (text === '行きつけのお店') { const flexMessage = await makeFlexMessage(userId); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } }; + // FlexMessageの「行きつけを解除」をタップしたらそのお店がDBから削除される + const actionDeleteFavoriteShop = async (event: WebhookEvent) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite !== -1) { + // Delete Gourmets_Favorite + await deleteFavorite(data, userId); + } + } catch (err) { + console.log(err); + } + }; これで完了です。 すべての機能を盛り込みました。 これでアプリとしては十分使えると思います。 まぁまだ問題点はあります。 FlexMessageは1度で12個しかスクロールできません。 なので、お気に入り店舗が12以上になると表示する方法がありません。 12以上の場合は複数回返信を行うように設定してもいいのですが、 店舗数が増えれば増えるほど見辛くなる問題も孕んでいます。 ただでさえ1つで画面占有の6割以上です。 これを2つ、3つと増やした場合はユーザビリティの悪化に繋がります。 なので残念ながらこれ以上の対応は思いつかないということで、簡易的なお気に入り機能として使っていただけると幸いです。 終わりに LINE Messaging APIを使うことでフロントの開発から解放されます。 LINEという身近なツールを使うことでインストールなどの手間もなく、これがあると便利だなってものを簡単に制作することができます。 ぜひ皆さんもLINE Bot開発をしてみてください。 ここまで読んでいただきありがとうございました。
- 投稿日:2021-08-09T15:43:56+09:00
【個人開発/LINE Messaging API】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(①)
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でコンパイルを行います。 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となるので、チャネルの種類を間違えた方は修正してください。 チャネルシークレットとチャネルアクセストークンが必要になるのでこの2つを発行します。 これで必要な環境変数は取得できました。 それでは、これをSSMを使ってLambda内で使えるようにしていきましょう。 SSMパラメータストアで環境変数を設定 なぜSSMパラメータストアを使うのか? SAMのLambda設定にも、環境変数の項目はあります。 しかし、2点問題点があります。 ①Lambdaの環境変数の変更をしたいとき、Lambdaのバージョンも新規発行をしなければならない ②Lambdaのバージョンとエイリアスを紐付けて管理をするとき、もし環境変数にリリース先環境別の値をセットしていると、リリース時に手動で環境変数の変更をしなければならないケースが発生する 簡単にまとめると、「リアルタイムで反映できないし、人為的なミスのリスクもあるよ」ということです。 SSMパラメータストアで値を管理すると以下の3点のメリットがあります。 ①Lambdaの環境変数の管理が不要 ②Lambdaも含めた値関連情報を一元管理できる ③Lambda外部からリアルタイムに環境変数を変更制御できる ということで、SSMパラメータストアを使用しましょう。 みんな大好きクラスメソッドの記事にやり方が書いてあります。 こちらの記事が完璧なのでこちらを見てやってみてください。 私は以下のように命名して作成しました。 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を確認しましょう。 Webhook URLの登録 先ほどAPI Gatewayで作成したhttpsのURLをコピーしてください。 これをLINE DevelopersのWebhookに設定します。 それではSSMパラメータが正しく取得できているか確認しましょう。 CloudWatchで確認しましょう! 取得できていますね! これで準備は完了です。 ここから飲食店検索の仕組みを作っていきましょう! アプリの流れ クライアント 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のコンソール画面に入って下さい。 コンソールに入ったらプロジェクトを作成しましょう! 私は、LINE-Node-TypeScript-Gourmetで作成しました。 では、ライブラリを有効化しましょう! 使うライブラリは2つです。 Map JavaScript API Places API お店検索をするAPIは「Places API」ですが、 JavaScriptから呼び出すために「Map JavaScript API」が必要となります。 ここまでできたら次にAPIを作成しましょう。 これからの開発はこちらのAPIキーを使います。 セキュリティ的には制限をつけたほうがいいのですが、今回はつけずに行います。 上記の説明でわからなければ以下のサイトを参考にされて下さい。 では取得したAPIをSSMパラメーターストアに登録しましょう。 方法は以下の通りです。 私はこのように命名しました。 ではこの値を関数内で使えるようにしましょう。 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 SearchのNearby 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で使うため 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を生成していけば完成です。 やっていきましょう! 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-09T15:18:10+09:00
node puppetterでwait系の処理を結構書いたので、メモ
const Util = require('./util'); const wait = { /** * 指定時間処理を止める */ sleep : async (time)=>{ if(time === undefined){ time = process.env.puppet_wait_ms; } return new Promise(resolve => { setTimeout(() => { resolve(); }, time); }); }, /** * 指定のエレメントにselecter要素が表示されるまで待つ * @param ElementHandle elem puppeteerのElementHandle * @param string selecter * @return Promise listで返す */ forChildSelecter:async (elem,selecter) =>{ let isShow = false; let targElms = {}; for(let i = 1 ; i <= process.env.puppet_wait_count; i++){ targElms = await elem.$$(selecter); if(targElms.length > 0){ isShow = true; break; } await wait.sleep(); } if(isShow === false){ throw new Error(process.env.puppet_wait_count+'回待ったけどダメ!'); }else{ return targElms; } }, /** * 指定のエレメントの中のselecter要素が消えるまで待つ * @param ElementHandle elem puppeteerのElementHandle * @param string selecter */ forChildSelecterHide: async(elm,selecter)=>{ let isShow = true; let targElms = {}; for(let i = 1 ; i <= process.env.puppet_wait_count; i++){ targElms = await elm.$$(selecter); if(targElms.length == 0){ isShow = false; break; } await wait.sleep(); } if(isShow === true){ throw new Error(process.env.puppet_wait_count+'回待ったけどダメ!'); }else{ return true; } }, /** * 指定のエレメントの中のselecter要素が作られて、消えるまで待つ * @param ElementHandle elem puppeteerのElementHandle * @param string selecter */ forChildSelecterShowAndHide:async (elm,selecter)=>{ await wait.forChildSelecter(elm,selecter); await wait.forChildSelecterHide(elm,selecter); return true; }, /** * ElementHandleから指定のClassNameが無くなるのを待つ。 * 再帰確認時間はenv.puppet_waitでms */ removeClass : async (elem,targClassName)=>{ let isRemove = false; for(let i = 1 ; i <= process.env.puppet_wait_count; i++){ let handle = await elem.getProperty('className'); let classNames = await handle.jsonValue(); if(classNames.indexOf(targClassName) == -1){ isRemove = true; break; } await wait.sleep(); } if(isRemove === false){ throw new Error(process.env.puppet_wait_count+'回待ったけどダメ!'); }else{ return true; } } }; module.exports = wait;
- 投稿日:2021-08-09T15:15:57+09:00
nodeで.envを作る
node.jsで設定ファイルを一か所に集めたくなったので、 envファイルで設定値を保存したくなったので調べたら dotenvっていうパッケージを見つけたので、 使ってみた。 dotenvのページ インストール npm install dotenv .envファイルを作る プロジェクトルートに.envファイルを作る 中身はこんな感じ /.env hoge = hogehogehogehoesetting # 環境変数を定義コメントはこんな感じ fuga = 設定値 使う index.jsに以下を配置 require('dotenv').config(); 他のファイルでは、読み込む必要は無い 設定値の読みだし console.log(process.env.hoge) // hogehogehogehoesetting って感じで、設定している文字列を取得できる ちょう簡単
- 投稿日:2021-08-09T14:39:50+09:00
Node.jsで簡易なWebシステムの構築②
目的 Node.jsを用いて簡易なWebシステムを構築する。Node.jsで簡易なWebシステムの構築①のアプリの拡張で、今回はデータの登録を可能にする。 環境条件 Node.jsで簡易なWebシステムの構築①で作業した環境をそのまま利用。 構築手順 ec2-userでログイン # rootユーザにスイッチ sudo su - 1.基本的な環境設定 #/opt/nodejsのディレクトリに移動 cd /opt/nodejs express-generatorを用いてアプリケーションのベースを構築。 #expressのインストール npm install express --save npm WARN saveError ENOENT: no such file or directory, open '/opt/nodejs/package.json' npm notice created a lockfile as package-lock.json. You should commit this file. npm WARN enoent ENOENT: no such file or directory, open '/opt/nodejs/package.json' npm WARN nodejs No description npm WARN nodejs No repository field. npm WARN nodejs No README data npm WARN nodejs No license field. express@4.17.1 added 50 packages from 37 contributors and audited 50 packages in 2.319s found 0 vulnerabilities #express-generatorのインストール npm install -g express-generator npm WARN deprecated mkdirp@0.5.1: Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.) /usr/local/bin/express -> /usr/local/lib/node_modules/express-generator/bin/express-cli.js + express-generator@4.16.1 added 10 packages from 13 contributors in 0.715s #myapp2という名前でアプリケーションのベースを構築 express -e myapp2 warning: option --ejs' has been renamed to--view=ejs' create : myapp2/ create : myapp2/public/ create : myapp2/public/javascripts/ create : myapp2/public/images/ create : myapp2/public/stylesheets/ create : myapp2/public/stylesheets/style.css create : myapp2/routes/ create : myapp2/routes/index.js create : myapp2/routes/users.js create : myapp2/views/ create : myapp2/views/error.ejs create : myapp2/views/index.ejs create : myapp2/app.js create : myapp2/package.json create : myapp2/bin/ create : myapp2/bin/www change directory: $ cd myapp2 install dependencies: $ npm install run the app: $ DEBUG=myapp2:* npm start #myapp2ディレクトリに移動 cd myapp2/ #npmパッケージのインストール npm install npm notice created a lockfile as package-lock.json. You should commit this file. added 54 packages from 38 contributors and audited 55 packages in 2.187s found 0 vulnerabilities #mysql関連ライブラリとexpress-validatorのインストール npm install --save mysql express-validator 2.アプリケーションの開発 今回のアプリケーションは、http://ホスト名:3000にアクセスするとNode.jsで簡易なWebシステムの構築①と同様に商品(果物)リストが表示され、その画面上にボタンを追加し、当該ボタンから商品登録画面に遷移し、登録が完了すると、登録後の情報を元の画面を更新して表示するという仕様とする。遷移先のURLはhttp://ホスト名:3000/insertとする。 express-generatorで生成された各種ファイルに対し、追記や変更、ファイル追加を実施することで構築している。 追記・変更したもの public/stylesheets/style.css routes/index.js views/index.ejs 追加したもの views/insert.ejs routes/index.js // 必要なライブラリの呼び出し const express = require('express'); const router = express.Router(); const mysql = require('mysql'); const { check, validationResult } = require('express-validator/check'); // mysql接続用の設定定義 const mysql_setting = { host: 'localhost', user: 'root', password: 'password', database: 'myappdb' } // http://<hostname>:3000にアクセスがきた際のレスポンス router.get('/', function(req, res, next) { // DBコネクションの生成 const connection = mysql.createConnection(mysql_setting); connection.connect(); // SQLの実行と結果の取得とindex.ejsへの伝達 connection.query('select * from myapptbl1', function (err, results, fields) { if (err) throw err res.render('index', { content: results }) }); // DBコネクションの破棄 connection.end(); }); // http://<hostname>:3000/insertにアクセスがきた際のレスポンス(insert.ejs) router.get('/insert', function (req, res, next) { const data = { errorMessage: '' } res.render('./insert', data); }); // http://<hostname>:3000/insertへアクセスが来た際のレスポンス // web画面上で入力された値が空になっていないかを確認し、エラーメッセージを表示 router.post('/insert', [check('name').not().isEmpty().trim().escape().withMessage('名前を入力して下さい'),check('price').not().isEmpty().trim().escape().withMessage('値段を入力して下さい'),], (req, res, next) => { const errors = validationResult(req); // 値が空の場合 if (!errors.isEmpty()) { const errors_array = errors.array(); res.render('./insert', { errorMessage: errors_array, }) } else { // 値が入力されている場合 // 画面上で入力された値を変数として定義 const name = req.body.name; const price = req.body.price; // SQL用に配列の作成と変数の入力 const post = { 'name': name, 'price': price }; // DBコネクションの生成 const connection = mysql.createConnection(mysql_setting); connection.connect(); // プレースホルダを用いてSQLを発行し、データを登録する connection.query('INSERT INTO myapptbl1 SET ?', post, function (error, results, fields) { if (error) throw error; // http://<ホスト名>:3000にリダイレクト(Insert後のデータを出力) res.redirect('./'); console.log('ID:', results.insertId); }); // DBコネクションの破棄 connection.end(); } }) module.exports = router; 以下、ejsファイルは大したこと書いてないので、細かい説明は割愛 views/index.ejs <!DOCTYPE html> <html> <head> <link rel='stylesheet' href='/stylesheets/style.css' /> </head> <body> <table> <thead> <tr> <th scope="col">id</th> <th scope="col">name</th> <th scope="col">price</th> </tr> </thead> <% for(let i in content) { %> <tr> <% let obj = content[i]; %> <th> <%= obj.id %> </th> <th> <%= obj.name %> </th> <th> <%= obj.price %> </th> </tr> <% } %> </table> <p class="bottom_space"></p> <button class="b1" onclick="location.href='./insert'">商品登録画面</button> </body> </html> views/insert.ejs <!DOCTYPE html> <html> <head> <link rel='stylesheet' href='/stylesheets/style.css' /> </head> <body> <p class="p1">商品を登録してください</p> <form action="/insert" method="post"> <input type="text" name="name" value="名前"> <input type="text" name="price" value="値段"> <button class="b2">登録</button> </form> <% if(errorMessage) { %> <ul> <% for (let n in errorMessage) { %> <li> <%= errorMessage[n].msg %> </li> <% } %> </ul> <% } %> </body> </html> 最低限の見栄えのために以下追記 (aタグまではデフォルト) public/stylesheets/style.css body { padding: 50px; font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; } a { color: #00B7FF; } table, tr, th { border-collapse: collapse; border:1px solid; font-size: 30px; } th { width: 400px; height: 50px; } table thead tr th { color: #fff; background: #000000; } .bottom_space { margin-bottom: 1em; } .b1 { width: 1204px; height: 50px; font-size: 30px; } .b2 { width: 400px; height: 50px; font-size: 30px; } .p1 { font-size: 50px; } input { width: 400px; height: 50px; font-size: 30px; } 3.画面イメージ http://<ホスト名>:3000 http://<ホスト名>:3000/insert 値の入力 登録後のリダイレクト
- 投稿日:2021-08-09T02:24:59+09:00
Ubuntuに最新版のNode.jsをインストールする
Ubuntuに最新版のNode.jsをインストールする方法を紹介します。 また、簡単にですがnpmの使い方も紹介します。 OSは「Ubuntu Desktop 18.04.5」です。 Node.jsのインストール バージョン14.15.5を利用するため、ディストリビューションの安定版ではなく、NodeSourceで管理されているPPAをインストールします。 まず、curlをインストールします。 $ sudo apt install -y curl curlコマンドでNodeSourceからPPAをダウンロードします。 続いて、ダウンロードしたモジュールのビルドに必要になるため、build-essentialをインストールします。 $ curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash - $ sudo apt install -y build-essential 下記コマンドでPPAをインストールします。 インストール後は、バージョン確認をします。 $ sudo apt install -y nodejs $ node -v v14.15.5 $ npm -v 6.14.11 npmについて 最初に初期化処理を行います。 初期化処理を行うと、package.jsonが生成されます。 package.jsonはjson形式で記載されたインストールしたライブラリを管理するための目録のような情報です。 ここでは、sampleというディレクトリ上で行いました。 $ mkdir sample $ cd sample $ npm init 例として、react、nextライブラリをインストールしてみます。 バージョンも指定しておきます。 $ npm install react@16.2.0 next@10.1.3 参考文献 Ethereum and Solidity: The Complete Developer's Guide 試して学ぶ スマートコントラクト開発 React.js&Next.js超入門
- 投稿日:2021-08-09T00:27:51+09:00
VOICEVOXをNode.jsから使ってみる。
思い出しながらソースをペーストしているので違ってる部分もあるかも こんなツイートがあったので試してみたのでメモ 「 #VOICEVOX を外部のソフトウェアから使えますか?」という質問をよく貰います。すごく簡単に使えます!VOICEVOXの音声合成エンジンはローカルにHTTPサーバーが立っているだけなので、こんな感じでPOSTを叩けばテキスト読み上げができます。詳しくは localhost:50021/docs をご覧ください。 pic.twitter.com/JFg2xMLgGK— ヒホ(ヒロシバ)?️ (@hiho_karuta) August 4, 2021 やりたいこと Node.jsを使用してVOICEVOXで読み上げデータを作りたい。 準備&インストール npm install axios npm install fs VOICEVOXに含まれている VOICEVOX-0.1.1-win/run.exe を起動しておく すると http://localhost:50021 に接続することができる。 http://localhost:50021/docs にかかれているメソッドの結果を組み合わせると音声パラメータや音源を生成することができる。 実装 //axios=通信するやつ //httpを省略するとうまく接続できなかったのでしっかり書いておく。 const rpc = axios.create({ baseURL: "http://localhost:50021", proxy: false }); //text:喋ってもらいたい言葉 //filepath:保存先 //ex:GenAudio("こんにちは","./greeting.wav"); async function GenAudio(text, filepath) { /* まずtextを渡して次のメソッド宛のパラメータを生成する、textはURLに付けるのでencodeURIで変換しておく。*/ const audio_query = await rpc.post('audio_query?text=' + encodeURI(text) + '&speaker=1'); //audio_queryで受け取った結果がaudio_query.dataに入っている。 //このデータをメソッド:synthesisに渡すことで音声データを作ってもらえる //audio_query.dataはObjectで、synthesisに送る為にはstringで送る必要があるのでJSON.stringifyでstringに変換する const synthesis = await rpc.post("synthesis?speaker=1", JSON.stringify(audio_query.data), { responseType: 'arraybuffer', headers: { "accept": "audio/wav", "Content-Type": "application/json" } }); //受け取った後、Bufferに変換して書き出す fs.writeFileSync(filepath, new Buffer.from(synthesis.data), 'binary'); } 偶に読み上げに失敗するとステータス500が返ってきたりするので、うまくいかないときは特殊文字とかが含まれてたり 読ませたい文字が長すぎではないかを確認する。 再起動などするとうまくいくときもある。 そのうちやりたいこと Chrome拡張とか作って選択箇所を読ませるのを作りたい。
- 投稿日:2021-08-09T00:27:51+09:00
Node.jsからVOICEVOXを使ってみる。
思い出しながらソースをペーストしているので違ってる部分もあるかも 解説というより思い出す為のメモ程度だと思ってください。 VOICEVOXとは つよつよエンジニアが作った音声読み上げソフト、詳しくはここ見て https://voicevox.hiroshiba.jp/ こんなツイートがあったので試してみた 「 #VOICEVOX を外部のソフトウェアから使えますか?」という質問をよく貰います。すごく簡単に使えます!VOICEVOXの音声合成エンジンはローカルにHTTPサーバーが立っているだけなので、こんな感じでPOSTを叩けばテキスト読み上げができます。詳しくは localhost:50021/docs をご覧ください。 pic.twitter.com/JFg2xMLgGK— ヒホ(ヒロシバ)?️ (@hiho_karuta) August 4, 2021 とりあえずやりたいこと Node.jsを使用してVOICEVOXで読み上げデータを作りたい。 準備&インストール npm install axios npm install fs VOICEVOXに含まれている VOICEVOX-0.1.1-win/run.exe を起動しておく すると http://localhost:50021 に接続することができる。 http://localhost:50021/docs にかかれているメソッドの結果を組み合わせると音声パラメータや音源を生成することができる。 実装 index.js const { default: axios } = require("axios"); const fs = require("fs"); //axios=通信するやつ //httpを省略するとうまく接続できなかったのでしっかり書いておく。 const rpc = axios.create({ baseURL: "http://localhost:50021", proxy: false }); //text:喋ってもらいたい言葉 //filepath:保存先 //ex:GenAudio("こんにちは","./greeting.wav"); async function GenAudio(text, filepath) { /* まずtextを渡してsynthesis宛のパラメータを生成する、textはURLに付けるのでencodeURIで変換しておく。*/ const audio_query = await rpc.post('audio_query?text=' + encodeURI(text) + '&speaker=1'); //audio_queryで受け取った結果がaudio_query.dataに入っている。 //このデータをメソッド:synthesisに渡すことで音声データを作ってもらえる //audio_query.dataはObjectで、synthesisに送る為にはstringで送る必要があるのでJSON.stringifyでstringに変換する const synthesis = await rpc.post("synthesis?speaker=1", JSON.stringify(audio_query.data), { responseType: 'arraybuffer', headers: { "accept": "audio/wav", "Content-Type": "application/json" } }); //受け取った後、Bufferに変換して書き出す fs.writeFileSync(filepath, new Buffer.from(synthesis.data), 'binary'); } GenAudio("こんにちは","./greeting.wav"); 偶に読み上げに失敗するとステータス500が返ってきたりするので、うまくいかないときは特殊文字とかが含まれてたり 読ませたい文字が長すぎではないかを確認する。 再起動などするとうまくいくときもある。 結果 greeting.wavが生成されている、再生すると可愛い声の「こんにちは」を聞くことができる。 そのうちやりたいこと Chrome拡張とか作って選択箇所を読ませるのを作りたい。 追記:作ってみたけど実用的ではない速度でした。(大体1ツイート30秒くらい)←しょうがない、というか十分速いです