9
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-07-01

AizuHack LINEBot勉強会 Vol.3

資料一覧

はじめに

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

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

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

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

  • axiosを使った外部APIとの通信
  • SheetDBを使った簡易データベース
    • node-json-dbを使った簡易データベースに変更しました
  • LINE Front-end Framework(LIFF)の使い方

外部APIとの通信

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

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

IMG_4929.jpg

今回使用するAPIのレスポンスはこちらから確認できます。

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

HTTPメソッドに関する解説はこちらの記事などがわかりやすいかと思います。

  1. Botのソースコードのディレクトリに移動して、npm i axiosを実行する
  2. text.jsの一番上の行にimport axios from 'axios';と追記
  3. textEvent関数のswitch文に以下のcaseを追加
// 天気予報というメッセージが送られてきた時
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

  1. Botのソースコードのディレクトリに移動して、npm i node-json-dbを実行する
  2. /dbmyDB.jsonを作成
  3. myDB.json{}を書き込んで保存
  4. 以下のコードをtext.jsimport axios from 'axios';の後に追加
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.jstextEvent関数内の先頭行に追加

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

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

// '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にデータを保存した場合以下のような形になります。

{
    "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

  1. text.jsconst myDB = new JsonDB(new Config('db/myDB.json', true, true, '/'));の下に以下のコードを追加する
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.jstextEvent関数内の、const { userId } = event.source;の下に以下のコードを追記します

// 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.jstextEvent関数内のevent.message.textのswitch文に以下のコードを追加します

// 'メモ'というメッセージが送られてきた時
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に関する詳細は公式ドキュメントから確認できます。

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

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

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

LIFFを作成しよう

  1. 先ほど作成したチャネルのページに移動する
  2. LIFFタブを開いてLIFFアプリを追加する
  3. LIFFアプリ名は適当な物を入力し、サイズはfull、エンドポイントURLはngrokのURLに/liffを追加したもの、Scopeはprofileopenidchat_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を閉じる

演習課題

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

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

演習課題1

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

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

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

LINE_capture_677231992.332368.JPG

演習課題2

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

  • NewsAPIを使用してください(アカウント登録が必要)

    • 今回は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

  • レベル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

演習課題3(応用課題)

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

要件は以下の通りです。

  • 本のISBNを送信したら蔵書情報が返ってくるシステムを作りましょう
  • 図書館APIを使用してください
  • アプリキーは環境変数に入れてください
  • APIの仕様書はこちらです
    • 会津大学図書館の蔵書であれば、https://api.calil.jp/check?appKey={アプリキー}&isbn={書籍のISBN}&systemid=Univ_Aizu&callback=noにGETリクエストを送ることで蔵書の取得ができます
    • 蔵書情報の取得に時間がかかる場合は、上記APIのレスポンスとしてセッションが返ってくるため、結果が取得できるまでhttps://api.calil.jp/check?appKey={アプリキー}&session={セッション}をループの中で2秒おきに叩いて、蔵書結果が返ってきたらループを抜けるという処理(ポーリング)を実装する必要があります
    • 2秒待つ処理は以下の通りに実装できます
await new Promise((resolve) => {
  setTimeout(resolve, 2000);
});
  • レベル1: 本の状態(貸出中かどうか)と、図書館の予約ページのリンク(レスポンスのreserveurl)、カリールのリンク(https://calil.jp/book/{ISBN})をテキストメッセージで返してみましょう
    • APIの規約上、カリールへのリンクを表示する必要があります。詳しくはAPIリファレンスをご覧ください
    • 図書館の予約ページのリンクがhttps://libeopsv.u-aizu.ac.jp/...になっていますが、このままだと証明書エラーになりアクセスできないため、libeopsvlibopsvに置換してから返信してください

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

LINE_capture_677257065.379838.JPG
LINE_capture_677257065.379838.JPG

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

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

ezgif-1-6c73dd930b.gif

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

おわりに

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

第4回はこちら

9
1
0

Register as a new user and use Qiita more conveniently

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