こまぶろ

技術のこととか仕事のこととか。

JS初心者がAWS Lambdaで実装するLINE Bot〜「オウム返し」の一歩先〜

先日まで、Vue.jsの勉強をしてきました。ところが、vue-cliがわけわからなすぎて「Node.jsも勉強しなきゃ」となっています。JavaScript自体にも理解が不足していることを実感してきました。

そこで、今回はNode.jsの勉強を兼ねてLINEのボット開発に挑戦です。以前、ネット上の記事をそのまま写す形で、Lambdaを用いてNode.jsでLINEのボット(オウム返しするだけ)作成していたので、今回はそちらを一歩先へ進めてみようと思います。

この記事が提供するもの

あらかじめお伝えしますが、この記事で紹介するのは、 LINEのMessaging APIを用いて簡単な会話を作成する方法 です。以下のようなことには触れません。

この記事で作成したコードのGitHubリポジトリ

ソースコードの全量および変更履歴はこちらをご参照ください。

github.com

今回の出発点:「オウム返し」

この記事の内容を概ねコピペした形となっています(最初のものを作ったのが少し前なので、そのときにどの記事をみて作成したのかは定かではありません)。

AWS、LINEの設定についてはこちらの記事の方が詳しいです。

今回の到達点

入力された文字列やボタンの選択に応じて、簡単なやりとりが成り立つようにしました。あらかじめ用意したJSON形式のメッセージを渡しているだけなので、チャットボットと言えるようなものではあいりません。あしからず。実際の画面は以下のような感じです。

f:id:ky_yk_d:20180517212449j:plain

修正後のコード(一部省略)

まずは、修正(機能追加+簡単なリファクタリング)後のコードを提示しておきます。大きな変更のなかった箇所については省略していますので、細かい修正履歴はGitHubをご覧ください(改善提案もお待ちしています)。

index.js

let https = require('https');

/**
 * Lambda実行時に呼び出されるハンドラ
 * @param {} event イベント
 * @param {} context コンテキスト
 * @param {} callback コールバック関数(正体は未確認)
 */
exports.handler = (event, context, callback) => {
    let messageObj;
    let replyToken;
    let jsonFile;
    let opts;
    let req;
    let data;
    let replyData;
    data = event.events[0];
    replyToken = data.replyToken;
// LINEでユーザから送られたテキストを取得
// メッセージオブジェクトを記述したJSONファイルを読み込む
// https://developers.line.me/ja/docs/messaging-api/reference/#anchor-e65d8a1fb213489f6475b06ad10f66b7b30b0072
    jsonFile = require("./dialogue.json");
// 入力に応じたメッサージの選択
    console.log(data);
    messageObj = getMessageObj(data, jsonFile);
// 返すデータを作成する
    console.log('データ作成');
    console.log(messageObj);
    replyData = JSON.stringify({
       replyToken: replyToken,
       messages: [
           messageObj
        ] 
    });
    console.log(replyData);
/* 〜共通部分中略〜 */
};

/**
 * 入力されたデータに応じて、返すメッセージを生成する関数
 * @param {} data 入力されたデータ
 * @param {} jsonFile 外部ファイルから読み込んだJSON形式のデータ
 * @return メッセージオブジェクト
 */
let getMessageObj = (data, jsonFile)=> {
    switch (data.type){
        case 'message':
            console.log('メッセージの場合');
            if (data.message.type != 'text'){
                // テキストメッセージ以外の場合
                console.log('テキスト以外のメッセージが入力された');
                return jsonFile.otherType;
            } else {
                // テキストメッセージの場合、入力された文字列に応じて分岐
                if (data.message.text == '住所') {
                    return jsonFile.dialogue2;
                } else {
                    return jsonFile.dialogue1;
                }
            }
        case 'postback':
            console.log('postbackの場合');
            return jsonFile[data.postback.data];
        default :
            console.log('それ以外の場合');
            console.log(data);
            return jsonFile.otherType;
    }
};

dialogue.json

{
    "dialogue1": {
        "type": "template",
        "altText": "テストメッセージ",
        "template": {
            "type": "buttons",
            "text": "メッセージありがとうございます!どんなことに興味がありますか?",
            "defaultAction": {
                "type": "uri",
                "label": "Twitterをみる",
                "uri": "https://twitter.com/ky_yk_d"
            },
            "actions": [
                {
                    "type": "uri",
                    "label": "ブログを読む",
                    "uri": "https://ky-yk-d.hatenablog.com/"
                },
                {
                    "type": "uri",
                    "label": "GitHubをみる",
                    "uri": "https://github.com/ky-yk-d"
                },
                {
                    "type": "postback",
                    "label": "次の質問に行く",
                    "data": "dialogue3",
                    "displayText": "別の質問がいい!"
                }
            ]
        }
   },
   "dialogue2": {
        "type": "location",
        "title": "東京スカイツリー",
        "address": "〒131-0045 東京都墨田区押上1丁目1−2",
        "latitude": 35.710139,
        "longitude": 139.810833
   },
   "dialogue3": {
        "type": "template",
        "altText": "メッセージ",
        "template": {
            "type": "buttons",
            "thumbnailImageUrl": "https://cdn-ak.f.st-hatena.com/images/fotolife/k/ky_yk_d/20180513/20180513094057.png",
            "imageAspectRatio": "rectangle",
            "imageSize": "cover",
            "imageBackgroundColor": "#000000",
            "text": "別の質問へを押しましたね?",
            "actions": [
                {
                    "type": "postback",
                    "label": "おわり",
                    "data": "end",
                    "displayText": "おわりにする"
                },
                {
                    "type": "uri",
                    "label": "やっぱりTwitter",
                    "uri": "https://twitter.com/ky_yk_d"
                }
            ]
        }
   },
   "otherType": {
       "type": "text",
       "text": "ごめんなさい!文章での入力をお願いします!"
   },
   "end": {
       "type": "text",
       "text": "お疲れ様でした!"
   }
}

メッセージオブジェクトを返却する

JSONファイル(dialogue.json)を新たに作成し、以下の部分で読み込んでいます。

jsonFile = require("./dialogue.json");

ここで読み込んだファイルには、複数のメッセージオブジェクトの内容が記載されています。下記の部分が一つのメッセージオブジェクトで、これを返却したものが上記のスクリーンショットの上部で表示されているものです。

"dialogue1": {
    "type": "template",
    "altText": "テストメッセージ",
    "template": {
        "type": "buttons",
        "text": "メッセージありがとうございます!どんなことに興味がありますか?",
        "defaultAction": {
            "type": "uri",
            "label": "Twitterをみる",
            "uri": "https://twitter.com/ky_yk_d"
        },
        "actions": [
            {
                "type": "uri",
                "label": "ブログを読む",
                "uri": "https://ky-yk-d.hatenablog.com/"
            },
            {
                "type": "uri",
                "label": "GitHubをみる",
                 "uri": "https://github.com/ky-yk-d"
            },
            {
                "type": "postback",
                "label": "次の質問に行く",
                "data": "dialogue3",
                "displayText": "別の質問がいい!" 
            }
        ]
    }
},

上記の例では、ボタンを表示するテンプレートメッセージを使用しており、それぞれのボタンには、リンクやpostback(後述)のアクションを持たせています。入力に応じて返すメッセージオブジェクトを切り替えることで、様々な機能を持ったボットを作成することができます。

様々なメッセージオブジェクト

他にも、メッセージオブジェクトには、以下のような種類があります。

  • テキストメッセージ
  • スタンプメッセージ
  • 画像メッセージ
  • 動画メッセージ
  • 音声メッセージ
  • 位置情報メッセージ
  • イメージマップメッセージ

以下は位置情報メッセージの例です。 f:id:ky_yk_d:20180517230336j:plain

"dialogue2": {
     "type": "location",
     "title": "東京スカイツリー",
     "address": "〒131-0045 東京都墨田区押上1丁目1−2",
     "latitude": 35.710139,
     "longitude": 139.810833
},

それぞれの細かい仕様については、LINEのMessaging APIリファレンスをご覧ください。

postbackイベントを使ってみる

さて、ここまででも色々なメッセージを応答として返すことができますが、まだ「決められた入力-決められた出力」しか実現できていません。Lambdaはあくまで関数なので、前回どのような入力があったかを保持することができず、流れのあるやりとりにはなりません。

流れのあるやりとりを実現するための方法として、postbackイベントが用意されています。冒頭の画像で、「別の質問がいい!」というテキストを送っているように見えますが、これは「次の質問へ行く」というボタンを押したときに表示されるテキストです。ソースコードでいうとこの部分。

{
    "type": "postback",
    "label": "次の質問に行く",
    "data": "dialogue3",
    "displayText": "別の質問がいい!"
}

label要素がボタンの文言で、displayTextが送信され(ているように見え)るテキストです。ユーザ側からはテキストメッセージを送っているようにしか見えないのですが、内部的には異なります。postbackイベントの場合、data要素を含めて送信されます。postbackイベントは、templateメッセージの選択肢に対応して送信されるイベントです。

今回の例では、postbackイベントの場合に、data要素で送られた文字列に対応するメッセージオブジェクトをJSONファイルから取得して返すようにしています。画像の例では、data要素でdialogue3という文字列を渡すようにすることで、JSONファイル内の"dialogue3"に対応するメッセージオブジェクトが返されるようになっています。

case 'postback':
    console.log('postbackの場合');
    return jsonFile[data.postback.data];

これを用いることで、流れある会話を実現することができるわけです。JSONファイルさえ用意できれば、何回もやりとりが続く応答も可能になります。ただ、やりとりが増えればその分だけJSONファイルの内容はカオスになっていくので、その辺りをどのように管理するかは今後の課題です。

おわりに

いかがでしたでしょうか。オウム返しだけではつまらなかったLINEボットも、やりとりが成立するようになると随分楽しくなりますね。多くの公式アカウントが存在していますが、一方的に情報を投げつけてくるだけで、こちらからメッセージを送っても「個別のメッセージにはお答えできません」と返ってくるだけのものも多いです。もちろん、今回の方法では、「りんな」のような対話ができるボットは作ることができませんが、一人で作って遊んだり仲間内で遊ぶ程度なら楽しいものが作れるのではないでしょうか。

また、今回条件分岐や関数のくくりだしなどを行ったことで、JavaScript的にもやや考えることがありました(下記におまけとして少し記載しておきます)。まだまだ、オブジェクト指向とは程遠いコードですが、機能追加をしながらJavaScriptのコードとしてもより良くしていけたらなと思っています。

エンジニアのためのJavadoc再入門講座 現場で使えるAPI仕様書の作り方

エンジニアのためのJavadoc再入門講座 現場で使えるAPI仕様書の作り方


おまけ:JavaScriptの変数がつらい話

今回条件分岐を追加したり、変数を新たに作成したりするなかで、若干リファクタリング的なことを行いながら進めました。そのうち大きな変更点としては、当初のソースコードで、var命令を用いていた箇所を、全てlet命令に変更し、また宣言の箇所もブロックの先頭に移動しました。
let命令はES6(ECMAScript2015)から使えるようになったものです。アロー関数も同じタイミングでの採用ということなので、アロー関数が使われていればletも使えるはずということで使ってみることにしました。varletでは、例えば以下のような違いがあるようです。

  • 同じ名称の変数宣言を許さない
  • ブロックスコープに対応している

(参考)

qiita.com

写経した段階のコードでは、ハンドラの冒頭でdata変数が明示的に宣言されずに使われていました。var命令がない場合、変数は(関数の中で初めて使用される場合でも)グローバル変数として宣言される(山田祥寛『JavaScript本格入門』188頁)とのことですが、今回の場合はさらに厄介だったようです。

というのも、関数内の後続する箇所でvar data = ...と宣言されているために、冒頭で利用されているdataもこのローカル変数と解釈されていたみたいです。「変数の巻き上げ」(山田189頁)と呼ばれるこの現象、var命令によって宣言されるローカル変数のスコープが関数全体(宣言箇所から関数の終わりまでではない!)であることによるものらしいのですが、極めて分かりにくいですね。

この点に限らず、ES6以前はブロックスコープ変数が存在しなかったり、Javaにもっとも親しんでいるプログラマとしてはJavaScriptは複雑怪奇です。JavaJavaScriptは全然似ていないというネタをよくみますが、ようやく実感できてきた気がします(とはいえ、JSDocはJavaDocと記法がほぼ一緒なので馴染みやすいです)。学びがいがありますね。