【2022年改訂版】AizuHack LINEBot勉強会 Vol.3

# AizuHack LINEBot勉強会 Vol.3

## 資料一覧

- [LINEBotとは](https://docs.google.com/presentation/d/1U1vl67gJNOdO5in7H__jPXWieT3Lv0cFmv350AmAKHA/edit?usp=sharing)
- [AizuHack LINEBot勉強会 Vol.1](https://qiita.com/shinbunbun_/items/7efef6db31514831143d)
- [AizuHack LINEBot勉強会 Vol.2](https://qiita.com/shinbunbun_/items/4034e9c2c7553ed3107e)
- AizuHack LINEBot勉強会 Vol.3（本記事）
- [AizuHack LINEBot勉強会 Vol.4](https://qiita.com/shinbunbun_/items/43e2c486ac8839a6d32e)

## はじめに

こんにちは、会津大学学部二年のしんぶんぶんです。

本記事はAizuHackのLINEBot勉強会 Vol.3の資料になります。

今回は、より高度なBotが作れるようになる技術を学びます。
今回やる技術を全て習得すれば、理論上はだいたいどんなBotでも作れるようになります。

具体的には以下の技術について学びます。

- axiosを使った外部APIとの通信
- ~~SheetDBを使った簡易データベース~~
  - node-json-dbを使った簡易データベースに変更しました
- LINE Front-end Framework（LIFF）の使い方
<!-- - eslintについて -->

## 外部APIとの通信

第2回まではLINE Messaging API内で完結することだけをやってきましたが、外部のAPIと連携することで作れるものの幅が圧倒的に広がります。
今回は、ちょっとまえ話題になった気象庁のAPIを使用して天気予報を取得してみます。

実装イメージは以下の通りです。

![IMG_4929.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/bc9ec56f-d2a1-c224-cc6e-e9da97adf792.jpeg)

今回使用するAPIのレスポンスは[こちら](https://www.jma.go.jp/bosai/forecast/data/forecast/070000.json)から確認できます。

APIでよく使うメソッドとしてGET, POST, PUT, DELETEの4つがあります。
GETはデータを取得する時、POSTはデータを送信する時、PUTはデータを更新・追加するとき、DELETEはデータを削除するときに使用します。
今回はデータを取得するため、GETを使います。

HTTPメソッドに関する解説は[こちら](https://developer.mozilla.org/ja/docs/Web/HTTP/Methods)の記事などがわかりやすいかと思います。

1. Botのソースコードのディレクトリに移動して、`npm i axios`を実行する
2. text.jsの一番上の行に`import axios from 'axios';`と追記
3. textEvent関数のswitch文に以下のcaseを追加

```javascript
// 天気予報というメッセージが送られてきた時
case '天気予報': {
  // axiosを使ってAPIにGETリクエストを送り、レスポンスのdataを変数resに格納
  const weatherApiRes = (await axios.get('https://www.jma.go.jp/bosai/forecast/data/forecast/070000.json')).data;
  // 返信するメッセージを作成
  message = {
    type: 'text',
    text: `【天気予報】
    
    ${weatherApiRes[0].timeSeries[0].timeDefines[0]}: ${weatherApiRes[0].timeSeries[0].areas[2].weathers[0]}
    ${weatherApiRes[0].timeSeries[0].timeDefines[1]}: ${weatherApiRes[0].timeSeries[0].areas[2].weathers[1]}
    ${weatherApiRes[0].timeSeries[0].timeDefines[2]}: ${weatherApiRes[0].timeSeries[0].areas[2].weathers[2]} 
    `,
  };
  break;
}
```

これで完成です！Botに`天気予報`と送ったら天気予報が返ってくるはずです。

## node-json-dbの使い方

Botの会話にコンテキスト（文脈）を持たせたり、データを保持するにはデータベースを使う必要があります。
今回はjsonファイルを簡易的なデータベースとして使える、node-json-dbの使い方を解説します。


### CRUDを実装してみよう

まずはCRUD（Create, Read, Update, Delete）を実装してみましょう。

実装イメージは以下の通りです。

![LINE_capture_677229343.716905.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/020d335f-3df6-671c-de78-4c22be0bda97.jpeg)

1. Botのソースコードのディレクトリに移動して、`npm i node-json-db`を実行する
2. `/db`に`myDB.json`を作成
3. `myDB.json`に`{}`を書き込んで保存
4. 以下のコードを`text.js`の`import axios from 'axios';`の後に追加

```js
import { JsonDB } from 'node-json-db';
import { Config } from 'node-json-db/dist/lib/JsonDBConfig.js';

const myDB = new JsonDB(new Config('db/myDB.json', true, true, '/'));
```

5.以下のコードを`text.js`の`textEvent`関数内の先頭行に追加

```javascript
// ユーザーIDを取得
const { userId } = event.source;
```

6.以下のコードを`text.js`の`textEvent`関数のswitch文に追加

```js
// 'Create'というメッセージが送られてきた時
case 'Create': {
  // DBにtestDataが存在しているかをチェック
  try {
    // DBからデータを取得（データがない場合は例外が投げられるのでcatchブロックに入る）
    myDB.getData(`/${userId}/testData`);
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: 'データはすでに存在しています',
    };
  } catch (_) {
    // 日時を取得
    const date = new Date();
    // DBにデータを追加
    myDB.push(`/${userId}/testData`, `Data created at ${date}`, false);
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: 'データが作成されました',
    };
  }
  break;
}
// 'Read'というメッセージが送られてきた時
case 'Read': {
  // DBにtestDataが存在しているかをチェック
  try {
    // DBからデータを取得（データがない場合は例外が投げられるのでcatchブロックに入る）
    const dbData = myDB.getData(`/${userId}/testData`);
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: `DBには以下のデータが保存されています\n\n${JSON.stringify(dbData)}`,
    };
  } catch (_) {
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: 'DBにデータが保存されていません',
    };
  }
  break;
}
// 'Update'というメッセージが送られてきた時
case 'Update': {
  // DBにtestDataが存在しているかをチェック
  try {
    // DBからデータを取得（データがない場合は例外が投げられるのでcatchブロックに入る）
    myDB.getData(`/${userId}/testData`);
    // 日時を取得
    const date = new Date();
    // DBのデータをアップデート(すでにデータがあるときにpushすると上書きされる)
    myDB.push(`/${userId}/testData`, `Data created at ${date}`, false);
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: 'データを更新しました',
    };
  } catch (_) {
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: 'DBにデータが保存されていません',
    };
  }
  break;
}
// 'Delete'というメッセージが送られてきた時
case 'Delete': {
  // DBにtestDataが存在しているかをチェック
  try {
    // DBからデータを取得（データがない場合は例外が投げられるのでcatchブロックに入る）
    myDB.getData(`/${userId}/testData`);
    // DBからデータを削除
    myDB.delete(`/${userId}/testData`);
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: 'DBのデータを削除しました',
    };
  } catch (_) {
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: 'DBにデータが保存されていません',
    };
  }
  break;
}
```

上から順番にCreate, Read, Update, Deleteのコードになっています。
それぞれのコードを実行して動きを確認してみましょう。
`/`が区切り文字になっており、例えば`/U6b53f4ad79a23f5427119cb44f08dbd7/testData`にデータを保存した場合以下のような形になります。

```json
{
    "U6b53f4ad79a23f5427119cb44f08dbd7": {
        "testData": "Data created at Tue Jun 14 2022 01:36:29 GMT+0000 (Coordinated Universal Time)"
    }
}
```

### コンテキスト管理を実装してみよう

現在みなさんが作っているBotは、1回メッセージを送ってそれに返信して終わりのため、連続的な会話を実装することができません。今回はコンテキスト管理を実装して会話状態を保持できるような仕組みを作っていきます。仕組みは以下の通りです。

1. `メモ開始`と送ったらデータ保存モードになる
2. 送ったメッセージがメモとしてDBに保存されて、データ保存モードが終了する
3. `メモ`と送ったらメモが表示される

実装イメージは以下の通りです。

![LINE_capture_677230712.453479.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/15c1aa2f-6557-4ca1-231e-96a86abf2f38.jpeg)

1. `text.js`の`const myDB = new JsonDB(new Config('db/myDB.json', true, true, '/'));`の下に以下のコードを追加する

```js
const contextDB = new JsonDB(new Config('db/contextDB.json', true, true, '/'));
const messageDB = new JsonDB(new Config('db/messageDB.json', true, true, '/'));
```

2.`/db`以下に`contextDB.json`, `messageDB.json`を作って`{}`を書き込む

3.`text.js`の`textEvent`関数内の、`const { userId } = event.source;`の下に以下のコードを追記します

```javascript
// DBからユーザーのデータを取得
let contextData;
let memoData;
try {
  contextData = contextDB.getData(`/${userId}/context`);
} catch (_) {
  contextData = undefined;
}
try {
  memoData = messageDB.getData(`/${userId}/memo`);
} catch (_) {
  memoData = undefined;
}

// contextDataで条件分岐
switch (contextData) {
  // もしそのユーザーのcontextがmemoModeだったら
  case 'memoMode': {
    // すでに保存されているメモがDBにある場合
    if (memoData) {
      // すでにあるmemoカラムに新しいメッセージを追加する
      memoData.push(event.message.text);
      // メッセージをDBへ保存
      messageDB.push(`/${userId}/memo`, memoData);
    } else {
      // memoカラムを作成してDBに保存
      messageDB.push(`/${userId}/memo`, [event.message.text]);
    }
    // contextをDBから削除
    contextDB.delete(`/${userId}/context`);
    // 返信するメッセージをreturnする
    return {
      type: 'text',
      text: `"${event.message.text}"というメッセージをdbに追加しました`,
    };
  }
  default:
    break;
}
```

4.`text.js`の`textEvent`関数内の`event.message.text`のswitch文に以下のコードを追加します

```js
// 'メモ'というメッセージが送られてきた時
case 'メモ': {
  // メモのデータがDBに存在する時
  if (memoData) {
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: `メモには以下のメッセージが保存されています\n\n${memoData}`,
    };
  } else {
    // 返信するメッセージを作成
    message = {
      type: 'text',
      text: 'メモが存在しません',
    };
  }
  break;
}
// 'メモ開始'というメッセージが送られてきた時
case 'メモ開始': {
  // DBにcontextを追加
  contextDB.push(`/${userId}/context`, 'memoMode' );
  // 返信するメッセージを作成
  message = {
    type: 'text',
    text: 'メモモードを開始しました',
  };
  break;
}
```

これで完成です！実際にメッセージを送信してどんな挙動をするのか試してみましょう。

## LINE Front-end Framework（LIFF）について

LIFFとは、LINEBotと連携したWebアプリケーションが簡単に作れるものです。
Webアプリ上でユーザーのプロフィールを取得したり、トークルームにメッセージを送ったりできます。

LIFFに関する詳細は[公式ドキュメント](https://developers.line.biz/ja/docs/liff/)から確認できます。

今回はWebアプリをこちらで用意しているので、それを使っていきましょう。

### LINEログインのチャネルを作成しよう

1. [LINE Developersのコンソール](https://developers.line.biz/console/)にアクセス
2. プロバイダーを選択
3. `新規チャネル作成`を選択
4. `LINEログイン`を選択
5. チャネル名、チャネル説明は適当なものを入力し、`アプリタイプ`はWebアプリを選択
6. 利用規約に同意して、チャネルを作成

<!-- ### Webアプリのファイルをローカルにダウンロードしよう

1. ソースコードのディレクトリに移動
2. `git clone`でファイルを落とした人は`git pull`、zipでダウンロードした人は再び[リポジトリ](https://github.com/shinbunbun/aizuhack-bot)からcodeをzipでダウンロードする
   1. `git pull`は一度commitしないとできないため、先に`git add .`、`git commit -m 'some fix'`を実行してcommitしてください
   2. zipでダウンロードした人は、元のコードの`vercel.json`を今ダウンロードしたものに置き換えて、`liff`フォルダを追加
3. `npx vercel deploy --prod`でデプロイする -->

### LIFFを作成しよう

1. 先ほど作成したチャネルのページに移動する
2. LIFFタブを開いてLIFFアプリを追加する
3. LIFFアプリ名は適当な物を入力し、サイズは`full`、エンドポイントURLはngrokのURLに`/liff`を追加したもの、Scopeは`profile`と`openid`と`chat_message.write`にチェックを入れ、ボットリンク機能はオフ、Scan QRはオンにして追加ボタンを押す
4. シェアターゲットピッカーをオンにする
5. LIFF IDをコピーして、liffディレクトリのindex.htmlの`LIFFID`を自分のLIFF IDに書き換える
6. チャネルの画面からLIFF URLをコピーする
7. LIFF URLをBotとのトーク画面に送って、そこからリンクを開く
8. 自分のプロフィールなどが表示されれば成功！

### 使える機能の解説

- liff.sendMessages()
  - Botとのトーク画面にメッセージを送信できます
- liff.shareTargetPicker()
  - 任意のトーク画面（例えば他の友達やグループ）にメッセージを送信できます
- liff.getProfile()
  - ユーザーのuserId、displayName（LINEのアカウント名）、pictureUrl（プロフィール画像のURL）、statusMessage（一言コメント）を取得できる
- liff.getOS()
  - ユーザーが使っているOSが取得できる
- liff.getLanguage()
  - ユーザーが使っている言語が取得できる
- liff.getVersion()
  - LIFFのバージョンが取得できる
- liff.getLineVersion()
  - ユーザーが使っているLINEのバージョンが取得できる
- liff.isInClient()
  - LIFFをLINEのアプリ内から実行しているかどうか取得できる
- liff.isLoggedIn()
  - ユーザーがログインしているかどうか取得できる
- liff.getContext()
  - LIFFアプリが起動された画面（1対1のトーク、グループ、トークルーム、または外部ブラウザ）に関する情報が取得できる
- liff.getFriendship()
  - ボットリンクをオンにすると、リンクしたbotとの友達関係が取得できる
- liff.scanCode()
  - LINEのQRコードリーダを起動できる
  - iOSでは使えない
- liff.closeWindow()
  - liffを閉じる

<!-- ### eslintについて

JavaScriptには、eslintというlinterが存在します。
簡単に言うと、構文ミスを見つけてくれたり、コードをきれいに整形してくれたりするやつです。

本当は`npm i -g eslint`を実行してグローバルにインストールし、`.eslintrc`という設定ファイルをワーキングディレクトリに作らないといけないのですが、今回はローカルインストールした上で設定ファイルもすでに用意してあるため、Botのソースコードがあるディレクトリで`npx eslint --fix .`と実行すればコードを自動整形できます。

ちなみに、`npm i -g eslint`でグローバルインストールした上でVS Codeのeslint拡張機能を入れれば、VS Code上で構文ミスや書き方が汚いところを全て指摘してくれるようになります。

VS Codeの設定ファイルをうまく書けば、VS Code上でファイルを保存するだけで自動整形してくれるようになるので、興味がある方はぜひ試してみてください。

## Bot事例紹介

自分や友達が作ったいろいろなBotの事例を紹介していきます。

- [自分が作ったBot](https://shinbunbun.info/products/)
  - 絞り込みボタンを押して、選択解除してからLINEBotを選択すればLINEBotだけがみれます
- [トラべる！](https://qiita.com/inoue2002/items/82f83d45233564f2bb52)
  - 広島県のレッドハッカソンで友達と作ったやつ -->

## 演習課題

### 演習課題に取り組む際に役立ちそうなTips

- [JSONきれい](https://tools.m-bsys.com/development_tooles/json-beautifier.php)
  - JSONを貼り付けたら綺麗に整形してくれるツールです
  - 初めて使うAPIはレスポンスの形がわからないので、いったんレスポンスを出力してJSONきれいで整形してみるとどんなレスポンスが返ってきているかを把握できます
  - GETリクエストの場合はエンドポイントURLをブラウザのアドレスバーに入力するだけでJSONを取得することができます
- [Postman](https://www.postman.com/)
  - GUIぽちぽちでAPIを叩けるツールです
  - ブラウザでも使えるし、インストールしても使えます

### 演習課題1

ToDoリストを作ってみましょう。要件は以下の通りです。

- タスクの追加, 一覧取得, タイトル更新, タスク削除ができる
- 一覧取得の時に、1から始めるインデックスをタスクの先頭に付与する

実装イメージは以下の通りです。

![LINE_capture_677231992.332368.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/ef6e9e89-c1e3-ba28-b108-f56ea8c1aeb1.jpeg)

### 演習課題2

直近のニュース一覧を取得してみましょう。要件は以下の通りです。

- [NewsAPI](https://newsapi.org/)を使用してください(アカウント登録が必要)
  - 今回は[Top headlines](https://newsapi.org/docs/endpoints/top-headlines)を使用しましょう
    - ドキュメントは[こちら](https://newsapi.org/docs/endpoints/top-headlines)から確認できます
  - ニュースを取得するときは5件まで取得してください
  - たとえばTop headlinesを5件取得する場合は`https://newsapi.org/v2/top-headlines?country=jp&apiKey={API_KEY}&pageSize=5`にGETリクエストを投げてください
  - 画像URL(`urlToImage`)、タイトル、概要(`description`)、公開日(`publishedAt`)、掲載元(`source.name`)、記事のURLをメッセージに含めてください
- App Keyは環境変数に入れてください
  - `.env`というファイルに`newsApiKey=XXXXXXX`という形式で記述すると、jsコード上で`process.env.newsApiKey`という形で呼び出せます
  - 環境変数が反映されない場合はサーバを再起動してみてください

- レベル1: 受け取ったニュースをテキストメッセージで返してください。実装イメージは以下の通りです。

![ezgif-4-dfbf45a6f9.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/9c0814cb-9c49-9913-7ff4-762da408d464.gif)

- レベル2: 受け取ったメッセージをFlexMessageで返してください
  - 1ニュース1カラムのカルーセルにしてください
  - FlexMessageのフィールドは空にできません(つまり、nullを指定することはできません)。そのため、APIから返ってきたレスポンスの各フィールドをチェックし、nullの場合はダミーの値をFlexMessageのフィールドに挿入してください。たとえば記事の画像URLは`urlToImage`というフィールドに入ってくるのですが、このフィールドがnullになっている場合、ダミーの値に差し替えずにそのままFlexMessageのフィールドに挿入すると、返信処理で400エラーになります。画像URLの場合はたとえば`https://raw.githubusercontent.com/shinbunbun/aizuhack-bot/master/media/imagemap.png`というダミー値を使うことなどが考えられます。
  - 記事のdescriptionが長いときにFlexMessageのレイアウトが崩れてしまうことがあります。100文字を超えた場合はそれ以降をカットするなどの処理を実装すると、綺麗に表示されられます。

実装イメージは以下の通りです

![ezgif-4-cfce146d0c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/edcdd37c-3a44-03c0-9961-c4acfc4707c5.gif)

### 演習課題3（応用課題）

会津大学附属図書館の蔵書を検索するシステムを作ってみましょう。

要件は以下の通りです。

- 本のISBNを送信したら蔵書情報が返ってくるシステムを作りましょう
- [図書館API](https://calil.jp/doc/api.html)を使用してください
  - [こちら](https://calil.jp/api/dashboard/)から利用申請ができます
- アプリキーは環境変数に入れてください
- APIの仕様書は[こちら](https://calil.jp/doc/api_ref.html)です
  - 会津大学図書館の蔵書であれば、`https://api.calil.jp/check?appKey={アプリキー}&isbn={書籍のISBN}&systemid=Univ_Aizu&callback=no`にGETリクエストを送ることで蔵書の取得ができます
  - 蔵書情報の取得に時間がかかる場合は、上記APIのレスポンスとしてセッションが返ってくるため、結果が取得できるまで`https://api.calil.jp/check?appKey={アプリキー}&session={セッション}`をループの中で2秒おきに叩いて、蔵書結果が返ってきたらループを抜けるという処理（ポーリング）を実装する必要があります
  <!-- - ポーリングするためのコードは以下の通りです -->
  - 2秒待つ処理は以下の通りに実装できます

```javascript
await new Promise((resolve) => {
  setTimeout(resolve, 2000);
});
```

<!-- ```js
// 蔵書検索の結果を格納する変数を宣言
let checkAPIResponse;
// eslint-disable-next-line no-constant-condition
while (true) {
  // eslint-disable-next-line no-await-in-loop
  await new Promise((resolve) => {
    setTimeout(resolve, 2000);
  });
  // eslint-disable-next-line no-await-in-loop
  checkAPIResponse = (await axios.get(`https://api.calil.jp/check?appKey=${calilAppKey}&session=${session}&callback=no`)).data;
  console.log(checkAPIResponse);
  if (checkAPIResponse.continue === 0) break;
}
// 蔵書検索の結果が取得できます
console.log(JSON.stringify(checkAPIResponse));
``` -->

- レベル1: 本の状態(貸出中かどうか)と、図書館の予約ページのリンク（レスポンスの`reserveurl`）、カリールのリンク（`https://calil.jp/book/{ISBN}`）をテキストメッセージで返してみましょう
  - APIの規約上、カリールへのリンクを表示する必要があります。詳しくは[APIリファレンス](https://calil.jp/doc/api_ref.html)をご覧ください
  - 図書館の予約ページのリンクが`https://libeopsv.u-aizu.ac.jp/...`になっていますが、このままだと証明書エラーになりアクセスできないため、`libeopsv`を`libopsv`に置換してから返信してください

レベル1の実装イメージは以下の通りです。

![LINE_capture_677257065.379838.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/4b56cca7-d311-a798-906b-476cc55d6cb1.jpeg)
LINE_capture_677257065.379838.JPG

- レベル2: [openBD](https://openbd.jp/)のAPIを使って、本のタイトル、作者、出版社、カバー画像を取得し、レベル1で取得した情報と合わせてFlexMessageを作成してみましょう
  - `https://api.openbd.jp/v1/get?isbn={ISBN}`にGETリクエストを送ることで本の情報が取得できます
  - レスポンスの`summary`カラムを参照すると良さげです

レベル2の実装イメージは以下の通りです。

![ezgif-1-6c73dd930b.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260292/f721828e-67ef-0117-b108-ce11a4415634.gif)

- レベル3（発展）: 現在はユーザーにISBNを送ってもらい、それを使って蔵書検索APIを叩く形になっています。書籍検索APIなどを使えば本のタイトルなどからISBNを取得できるため、それを使ってタイトル検索やフリーワード検索を実装してみましょう
  - 本をフリーワード検索できるAPIは色々あるため、どれを使ってもOKです
- レベル4（発展）: 現在の状態ではポーリングが発生した場合にユーザーへ返信するのが2秒以上遅れてしまうため、UXが悪いです。先に「検索中なので少々お待ちください」などのメッセージを返信し、後からPUSHメッセージで結果を返すように修正してみましょう

## おわりに

皆さんお疲れ様でした。第4回では、GCPのCloud Runへのデプロイについて解説します。

第4回は[こちら](https://qiita.com/shinbunbun_/items/43e2c486ac8839a6d32e)

<!-- これでAizuHack LINEBotコースの全ての勉強会が終わりました。皆さん本当にお疲れ様でした。
ここまでに学んだ技術を使えば、もうたいていのBotは作れます。
ぜひ面白いBotをたくさん作ってみてください。

また、本記事で使用しているサンプルコードは複数のコントリビュータのサポートにより完成したものです。
この場を借りて心より御礼申し上げます。 -->