5
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Actions on Google向けに作ったDialogflowボットをLINEボットにする

Last updated at Posted at 2019-01-13

以前DialogflowでLINEボットを作成しました。

 Dialogflowと連携してLINE Botを作る

これでもとりあえず良いのですが、見た目がちょっとチープな気がします。

一方で、LINE Beaconでもボットを作成しました。

 LINE Beaconを自宅に住まわせる

ですが、Dialogflowは、type=messageのイベントには対応していますが、LINE Beaconやフォロー・フォロー解除イベントなどの特殊なイベントには対応していません。

また、LINEボットより、Actions on GoogleのボットをDialogflowで作ることの方が多いので、できれば、Actions on Google向けのボットの実装がLINEにも転送できればベターです。

ということで、タイトルにもある通り、「Actions on Google向けに作ったDialogflowボットをLINEボットにする」をしたいと思います。
Actions on Googleを前提にDialogflowボットを作っておけば、LINEにも転用できることを期待しています。
Actoins on Googleで、いくつかリッチレスポンスが定義されていますが、LINEでも表示できるように、その中でよく使う以下のレスポンスを、LINEに対応させます。

  • SimpleResponse
  • BasicCard
  • List
  • Carousel
  • Suggestion

結果として、以下の流れになります。

<基本形>
 (発話) ⇔ Actions on Google ⇔ Dialogflow ⇔ RESTfulサーバ①

<LINEの場合>
 (発話) ⇔ LINE(or LINE Beacon) ⇔ RESTfulサーバ② ⇔ Dialogflow ⇔ RESTfulサーバ①

自作するRESTfulサーバのうち、RESTfulサーバ①は、対抗がActions on Googleであるとみなして動作します。RESTfulサーバ①とDialogflowは、基本形とLINEの場合で共通です。
かなめは、RESTfulサーバ②の部分で、Actions on Google向けのレスポンスをLINE用に変換します。

(以下参考情報)

LINE Developers
https://developers.line.biz/console/

LINE Flex Message
 https://developers.line.biz/ja/reference/messaging-api/#flex-message

line-bot-sdk-node.js
 https://line.github.io/line-bot-sdk-nodejs/api-reference/client.html#methods

Dialogflow
 https://dialogflow.com/

nodejs-dialogflow
 https://github.com/googleapis/nodejs-dialogflow

actions on google sdk node.js
 https://github.com/actions-on-google/actions-on-google-nodejs

Actions on Google Responses
 https://developers.google.com/actions/assistant/responses

まずは基本形を作る

まずは純粋に、Dialogflowと連携して、Actions on Googleからの発話に応答するようにします。

DialogflowコンソールからAgentを作成し、以下の5つのIntentを追加します。

image.png

  • SimpleResponse
     Training phrases : シンプル

  • BasicCard
     Training phrases : ベーシック

  • List
     Training phrases : リスト

  • Carousel
     Training phrases : カルーセル

  • Suggestion
     Training phrases : サジェスチョン

すべて、WebhookでRESTfulサーバ①に飛ばすようにします。

image.png

FullfillmentのWebhookのURLにこれから立ち上げるRESTfulサーバ①のURLを指定して、ENABLED状態にしておきます。

image.png

あとで、作成したAgentのProject IDを後で使うので、覚えておきます。
Agent名の右側の歯車をクリックすると表示されるページに記載されています。

image.png

それから、RESTfulサーバ②からDialogflowを呼び出すためのサービスアカウントの作成が必要です。
Project IDのところにある Google Cloud をクリックして、Google Cloud Platformコンソールを開きます。

image.png

そして、左メニューから、「APIとサービス」 → 「認証情報」を選択します。

image.png

ここで、「認証情報を作成」ボタンを押下し、サービスアカウントキーを選択します。
サービスアカウントとして、「新しいサービスアカウント」、サービスアカウント名に適当な名前、役割にはとりあえず「Project→オーナー」を選択しておきます(後でちゃんと役割を絞ってください)。キータイプはJSONを選びます。

image.png

そうすると、シークレット情報が書かれたJSONファイルがダウンロードされます。
このファイルは後で使います。

RESTfulサーバ①を立ち上げる

まずは、RESTfulサーバ①の実装を示します。
以下のnpmモジュールを使っています。

  • actions-on-google
index.js
'use strict';

const {dialogflow, SimpleResponse, BasicCard, Image, Button, List, Carousel, Suggestions} = require('actions-on-google');
const app = dialogflow({debug: true});

app.intent('SimpleResponse', (conv) =>{
    conv.ask('これはSimpleResponseに対する応答です。');
    conv.ask(new SimpleResponse({
        speech: '音声です。(LINE未対応)',
        text: 'テキストです。'
    }));
});

app.intent('BasicCard', (conv) =>{
    conv.ask('これはBasicCardに対する応答です。');

    conv.ask(new BasicCard({
        title: 'じゃんけん',
        subtitle: 'じゃんけんゲーム',
        text: 'じゃんけんゲームです。',
        image: new Image({
            url: "https://github.com/poruruba/media/blob/master/rock_paper_scissors.png?raw=true",
            alt: "アイコン画像"
        }),
        buttons: new Button({
            title: 'Wikipediaはこちら',
            url: 'https://ja.wikipedia.org/wiki/%E3%81%98%E3%82%83%E3%82%93%E3%81%91%E3%82%93'
        }),
        display: 'CROPPED'
    }));
});

app.intent('Carousel', (conv) =>{
    conv.ask('これはCarouselに対する応答です。');

    conv.ask(new Carousel({
        items: {
            ['グー'] : {
                synonyms:[
                    'グー'
                ],
                title: 'じゃんけんグー',
                description: 'グーを出します。',
                image: new Image({
                    url: 'https://github.com/poruruba/media/blob/master/rock.png?raw=true',
                    alt:'じゃんけんグー'
                })
            },
            ['チョキ'] : {
                synonyms:[
                    'チョキ'
                ],
                title: 'じゃんけんチョキ',
                description: 'チョキを出します。',
                image: new Image({
                    url: 'https://github.com/poruruba/media/blob/master/scissors.png?raw=true',
                    alt:'じゃんけんチョキ'
                })
            },
            ['パー'] : {
                synonyms:[
                    'パー'
                ],
                title: 'じゃんけんパー',
                description: 'パーを出します。',
                image: new Image({
                    url: 'https://github.com/poruruba/media/blob/master/paper.png?raw=true',
                    alt:'じゃんけんパー'
                })
            }
        }
    }));
});

app.intent('List', (conv) =>{
    conv.ask('これはListに対する応答です。');

    conv.ask(new List({
        title: 'じゃんけんの手を選んでください。',
        items: {
            ['グー'] : {
                synonyms:[
                    'グー'
                ],
                title: 'じゃんけんグー',
                description: 'グーを出します。',
                image: new Image({
                    url: 'https://github.com/poruruba/media/blob/master/rock.png?raw=true',
                    alt:'グー1'
                })
            },
            ['チョキ'] : {
                synonyms:[
                    'チョキ'
                ],
                title: 'じゃんけんチョキ',
                description: 'チョキを出します。',
                image: new Image({
                    url: 'https://github.com/poruruba/media/blob/master/scissors.png?raw=true',
                    alt:'じゃんけんチョキ'
                })
            },
            ['パー'] : {
                synonyms:[
                    'パー'
                ],
                title: 'じゃんけんパー',
                description: 'パーを出します。',
                image: new Image({
                    url: 'https://github.com/poruruba/media/blob/master/paper.png?raw=true',
                    alt:'じゃんけんパー'
                })
            }
        }
    }));    
});

app.intent('Suggestion', (conv) =>{
    conv.ask('これはSuggestionに対する応答です。');

    conv.ask('じゃんけんの手を選んでください。');
    conv.ask(new Suggestions(['グー', 'チョキ', 'パー']));
});

exports.fulfillment = app;

以下のXXXXXXにインテント名を指定して、関数の中身で対応するレスポンスを作成しています。

app.intent('XXXXXXX', (conv) =>{
});

試しに、AndroidのActions on Googleから呼び出してみます。

> テスト用アプリにつないで
< こんにちは。
>ベーシック

と呼び出すことで、BasicCardインテントが呼び出されます。

image.png

LINEボットを設定する

それではこれから、LINEアプリとの連携に進みます。

LINE Developersコンソールから、プロバイダを作成します。(まだ作成していない場合)
 https://developers.line.biz/console/

そして、新規チャネルを作成します。
チャネルはMessaging APIを選択します。
プランは、フリーを選択しました。
アクセストークン(ロングターム)はまだ発行していないと思いますので、「再発行」ボタンを押下します。
Webhook送信は、「利用する」を選択しておきます。
Webhook URLは、これから立ち上げるRESTfulサーバ②のURLを指定します。
自動応答メッセージは、「利用しない」にしておきます。

Channel Secretとアクセストークン(ロングターム)は後で使うので、メモっておきます。

LINE Flex Messageをデザインする

Actions on Googleで定義されているリッチレスポンスと同じようなUIをLINEでも表示させたいのですが、ぴったり一致するものはなかっため、LINE Flex Messageで表現します。

以下の、「Flex Message Simulator」を使うと、実際のUI画面を見ながら作れます。
 https://developers.line.biz/console/fx/

image.png

以下のリッチレスポンスのUIを作成します。Suggestionsは画面がないので、作成不要です。

  • SimpleResponse
  • BasicCard
  • List
  • Carousel

作成したこのJSONをRESTfulサーバ①からの応答内容をもとに動的に作成する必要があるため、このJSONを参考にして、のちほどのソースコードに埋め込んでいます。

RESTfulサーバ②を立ち上げる

RESTfulサーバ②は、LINEアプリからのメッセージを受け付け、Dialogflowに転送します。逆に受け取ったレスポンスをLINE用に変換して戻します。
そこで、以下のnpmモジュールを使いました。

  • @line/bot-sdk
  • dialogflow
  • uuid

2点補足します。

1点目は、Dialogflowに転送してからその応答が返ってきますが、その内容は、Actions on Google用に最適化されています。
具体的には、ProtoBuf形式になっています。それをJSON形式にパースしたのち、LINEのレスポンス形式に変換しています。
パースには、nodejs-dialogflowに含まれているstructjson.jsを流用させていただきました。

(このJSファイルのおかげでだいぶ助かりましたし、見つけるのにも結構時間がかかりました。余裕があれば、Google protobufはどんなものか、responses[0].queryResult.webhookPayload の中身を見てみてください)

2点目は、LINEメッセージの受信をフィルタリングするため、LineUtilsというクラスを作成しています。

以下の関数の部分で、type=messageのメッセージを処理します。
処理は、Dialogflowへの呼び出しと、レスポンスのLINE用変換です。

app.message((event, client) =>{
});

LINE Beaconに対応する場合は、以下の関数実装で、type=beaconのメッセージイベントを処理します。

app.beacon((event, client) =>{
});

(app.XXXXXの参考情報)
LINE Beaconを自宅に住まわせる

index.js
const config = {
    channelAccessToken: LINEのアクセストークン(ロングターム),
    channelSecret: LINEのChannel Secret
};

const LineUtils = require('../../helpers/line-utils');
const app = new LineUtils(config);

const uuid = require('uuid/v4');
const StructJson = require('../../helpers/structjson');

const PROJECT_ID = DialogflowのProject ID;

const dialogflow = require('dialogflow');

const sessionClient = new dialogflow.SessionsClient();
const sessionId = uuid();
const sessionPath = sessionClient.sessionPath(PROJECT_ID, sessionId);

app.message((event, client) =>{
    console.log('app.message called', event.source.userId);
    if( event.message.type == 'text'){
        const request = {
            session: sessionPath,
            queryInput: {
                text: {
                    text: event.message.text,
                    languageCode: 'ja-JP',
                }
            }
        };
    
        return sessionClient.detectIntent(request)
        .then(responses =>{
            console.log('responses=', responses);
            if( !responses[0].queryResult.webhookPayload ){
                return client.replyMessage(event.replyToken, { type: 'text', text: responses[0].queryResult.fulfillmentText });
            }

            var json = StructJson.structProtoToJson(responses[0].queryResult.webhookPayload);
            console.log(json);

            var message_list = app.convertMessages(json.google);
            return client.replyMessage(event.replyToken, message_list );
        })
        .catch(error =>{
            console.log(error);
        });
    }else{
        console.log('Not supported');
    }
});

exports.handler = app.lambda();

以下、ユーティリティです。
line-util.jsで、Actions on Google向けのレスポンスメッセージを、LINE用に変換しています。

line-util.js
'use strict';

const line = require('@line/bot-sdk');
const Response = require('./response');

class LineUtils{
    constructor(config){
        this.client = new line.Client(config);
        this.map = new Map();
    }

    message(handler){
        this.map.set('message', handler);
    }

    follow(handler){
        this.map.set('follow', handler);
    }

    unfollow(handler){
        this.map.set('unfollow', handler);
    }

    join(handler){
        this.map.set('join', handler);
    }

    leave(handler){
        this.map.set('leave', handler);
    }

    memberJoined(handler){
        this.map.set('memberJoined', handler);
    }

    memberLeft(handler){
        this.map.set('memberLeft', handler);
    }

    postback(handler){
        this.map.set('postback', handler);
    }

    beacon(handler){
        this.map.set('beacon', handler);
    }

    accountLink(handler){
        this.map.set('accountLink', handler);
    }

    things(handler){
        this.map.set('things', handler);
    }

    lambda(){
        return async (event, context, callback) => {
            var body = JSON.parse(event.body);

            return Promise.all(body.events.map((event) =>{
                if( (event.type == 'message') &&
                     (event.replyToken === '00000000000000000000000000000000' || event.replyToken === 'ffffffffffffffffffffffffffffffff' ))
                    return;

                var handler = this.map.get(event.type);
                if( handler )
                    return handler(event, this.client);
                else
                    console.log(event.type + ' is not defined.');
            }))
            .then((result) =>{
//                console.log(result);
//                return new Response(result);
                return new Response({});
            })
            .catch((err) => {
                console.error(err);
                const response = new Response();
                response.set_error(err);
                return response;
            });
        }
    }

    convertMessages(google){
        var has_suggestion = false;
        if( google.richResponse && google.richResponse.suggestions )
            has_suggestion = true;

        var message_list = [];
        if( google.richResponse && google.richResponse.items ){
            for( var i = 0 ; i < google.richResponse.items.length ; i++ ){
                if( google.richResponse.items[i].simpleResponse )
                    message_list.push(this.convertSimpleResponse(google.richResponse.items[i].simpleResponse));
            }
        }
        if( has_suggestion ){
            if( message_list.length == 0){
                console.log('suggestion condition error');
            }else{
                var text = message_list.pop();
                message_list.push(this.convertSuggestions(text.text, google.richResponse.suggestions));
            }
        }

        if( google.richResponse && google.richResponse.items ){
            for( var i = 0 ; i < google.richResponse.items.length ; i++ ){
                if( google.richResponse.items[i].basicCard ){
                    message_list.push(this.convertBasicCard(google.richResponse.items[i].basicCard));
                }else if( google.richResponse.items[i].simpleResponse ){
                    // already processed
                }else{
                    console.log('Not supported message type');
                }
            }
        }

        if( google.systemIntent && google.systemIntent.data ){
            if( google.systemIntent.data.listSelect )
                message_list.push(this.convertList(google.systemIntent.data.listSelect));
            else if( google.systemIntent.data.carouselSelect )
                message_list.push(this.convertCarousel(google.systemIntent.data.carouselSelect));
            else
                console.log('Not supported message type');
        }

        return message_list;
    }

    convertSuggestions(text, suggestions){
        return this.createSuggestion(text, suggestions);
    }

    /* list = [] */
    createSuggestion(text, list){
        var quick = {
            type: "text",
            text: text,
            quickReply: {
                items: []
            }
        };

        for( var i = 0 ; i < list.length ; i++ ){
            if( typeof list[i].title == 'string' || list[i].title instanceof String ){
                var action = {
                    type: 'action',
                    action: {
                        type : 'message',
                        label: list[i].title,
                        text: list[i].title
                    }
                };
                quick.quickReply.items.push(action);
            }else{
                console.log('Not supported');
            }
        }

        return quick;
    }

    convertSimpleResponse(simpleResponse){
        var message = simpleResponse.displayText;
        if( !message ) 
            message = simpleResponse.textToSpeech;
        return this.createSimpleResponse(message);
    }

    convertBasicCard(basicCard){
        var button = basicCard.buttons[0];
        return this.createBasicCard(basicCard.title, basicCard.subtitle, basicCard.image.url, basicCard.formattedText, button.title, button.openUrlAction.url );
    }

    convertList(listSelect){
        var list = [];
        for( var i = 0 ; i < listSelect.items.length ; i++ ){
            list.push({
                title: listSelect.items[i].title,
                desc: listSelect.items[i].description,
                image_url: listSelect.items[i].image.url,
                message: listSelect.items[i].optionInfo.key
            });
        }
        return this.createList(listSelect.title, list );
    }

    convertCarousel(carouselSelect){
        var list = [];
        for( var i = 0 ; i < carouselSelect.items.length ; i++ ){
            list.push({
                title: carouselSelect.items[i].title,
                desc: carouselSelect.items[i].description,
                image_url: carouselSelect.items[i].image.url,
                message: carouselSelect.items[i].optionInfo.key
            });
        }
        return this.createCarousel(list);
    }

    createSimpleResponse(text){
        return { type: 'text', text: text };
    }

    createBasicCard(title, sub_title, image_url, text, btn_text, url ){
        var flex = {
            type: "flex",
            altText: title,
            contents: {
                type: "bubble",
                hero: {
                    type: "image",
                    url: image_url,
                    size: "full"
                },
                body: {
                    type: "box",
                    layout: "vertical",
                    contents: [
                        {
                            type: "box",
                            layout: "vertical",
                            contents: [
                                {
                                    type: "text",
                                    text: title,
                                    weight: "bold",
                                    size: "md"
                                },
                                {
                                    type: "text",
                                    text: sub_title,
                                    color: "#aaaaaa",
                                    size: "xs",
                                    wrap: true
                                },
                                {
                                    type: "spacer",
                                    size: "sm"
                                }
                            ]
                        },
                        {
                            type: "text",
                            text: text,
                            size: "sm",
                            wrap: true
                        }
                    ]
                },
                footer: {
                    type: "box",
                    layout: "vertical",
                    contents: [
                        {
                            type: "button",
                            style: "link",
                            height: "sm",
                            action: {
                                type: "uri",
                                label: btn_text,
                                uri: url
                            }
                        }
                    ],
                    flex: 0
                }
            }
        }

        return flex;
    }

    /* list = [ { title, desc, image_url, message } ] */
    createList(title, list){
        var flex = {
            type: "flex",
            altText: title,
            contents: {
                type: "bubble",
                styles: {
                    header: {
                        backgroundColor: "#eeeeee"
                    }
                },
                header: {
                    type: "box",
                    layout: "horizontal",
                    contents: [
                        {
                            type: "text",
                            text: title,
                            size: "sm",
                            color: "#777777"
                        }
                    ]
                },
                body: {
                    type: "box",
                    layout: "vertical",
                    contents: []
                }
            }
        };

        for( var i = 0 ; i < list.length; i++ ){
            if( i != 0 ){
                flex.contents.body.contents.push({
                    type: "separator",
                    margin: 'md'
                });
            }

            var option = {
                type: "box",
                layout: "horizontal",
                margin: 'md',
                contents: [
                    {
                        type: "box",
                        layout: "vertical",
                        flex: 4,
                        contents: [
                            {
                                type: "text",
                                weight: "bold",
                                text: list[i].title,
                                size: "sm"
                            },
                            {
                                type: "text",
                                text: list[i].desc,
                                color: "#888888",
                                size: "xs",
                                wrap: true
                            }
                        ]
                    },
                    {
                        type: "image",
                        url: list[i].image_url,
                        size: "sm",
                        flex: 1
                    }
                ],
                action: {
                    type: "message",
                    text: list[i].message
                }
            };

            flex.contents.body.contents.push(option);
        }

        return flex;
    }

    /* list = [title, desc, image_url, message] */
    createCarousel(list){
        var flex =  {
            type: "flex",
            altText: "#",
            contents: {
                "type": "carousel",
                "contents": []
            }
        };

        for( var i = 0 ; i < list.length ; i++ ){
            var option = {
                type: "bubble",
                hero: {
                    type: "image",
                    url: list[i].image_url,
                    size: "full"
                },
                body: {
                    type: "box",
                    layout: "vertical",
                    contents: [
                        {
                            type: "box",
                            layout: "vertical",
                            contents: [
                                {
                                    type: "text",
                                    text: list[i].title,
                                    weight: "bold",
                                    size: "sm"
                                },
                                {
                                    type: "text",
                                    text: list[i].desc,
                                    color: "#aaaaaa",
                                    size: "xs",
                                    wrap: true
                                }
                            ]
                        }
                    ],
                    action: {
                        type: "message",
                        text: list[i].message
                    }
                }
            };

            flex.contents.contents.push(option);
        }

        return flex;
    }
};

module.exports = LineUtils;
response.js
class Response{
    constructor(context){
        this.statusCode = 200;
        this.headers = {'Access-Control-Allow-Origin' : '*'};
        if( context )
            this.set_body(context);
        else
            this.body = "";
    }

    set_error(error){
        this.body = JSON.stringify({"err": error});
        return this;
    }

    set_body(content){
        this.body = JSON.stringify(content);        
        return this;
    }
    
    get_body(){
        return JSON.parse(this.body);
    }
}

module.exports = Response;

以下の部分は、環境に合わせて修正してください。
 【LINEのアクセストークン(ロングターム)】
 【LINEのChannel Secret】
 【DialogflowのProject ID】

ここで1つ忘れずにやっておくことがあります。
Google Cloud Platformコンソールで作成したシークレット情報ファイルを参照できる場所に置きます。
その場所を環境変数「GOOGLE_APPLICATION_CREDENTIALS」に示しておきます。
例えば、こんな感じです。

 GOOGLE_APPLICATION_CREDENTIALS="./cert/google/bottest-XXXXX-XXXXXXXXX.json"

LINEアプリからアクセスしてみる

まずは、LINEアプリから、作成したチャネルとLINEボットとして友達登録します。
LINE Developersコンソールのチャネルの基本情報にあるQRコードを使うのが楽ちんです。

image.png

image.png

友達登録したのち、「シンプル」「ベーシック」「リスト」「カルーセル」「サジェスチョン」を試してみてください。Actions on GoogleとそっくりのUIが返ってくることがわかります。

image.png

image.png

image.png

image.png

image.png

以上です。

5
9
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
5
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?