以前DialogflowでLINEボットを作成しました。
これでもとりあえず良いのですが、見た目がちょっとチープな気がします。
一方で、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を追加します。
-
SimpleResponse
Training phrases : シンプル -
BasicCard
Training phrases : ベーシック -
List
Training phrases : リスト -
Carousel
Training phrases : カルーセル -
Suggestion
Training phrases : サジェスチョン
すべて、WebhookでRESTfulサーバ①に飛ばすようにします。
FullfillmentのWebhookのURLにこれから立ち上げるRESTfulサーバ①のURLを指定して、ENABLED状態にしておきます。
あとで、作成したAgentのProject IDを後で使うので、覚えておきます。
Agent名の右側の歯車をクリックすると表示されるページに記載されています。
それから、RESTfulサーバ②からDialogflowを呼び出すためのサービスアカウントの作成が必要です。
Project IDのところにある Google Cloud をクリックして、Google Cloud Platformコンソールを開きます。
そして、左メニューから、「APIとサービス」 → 「認証情報」を選択します。
ここで、「認証情報を作成」ボタンを押下し、サービスアカウントキーを選択します。
サービスアカウントとして、「新しいサービスアカウント」、サービスアカウント名に適当な名前、役割にはとりあえず「Project→オーナー」を選択しておきます(後でちゃんと役割を絞ってください)。キータイプはJSONを選びます。
そうすると、シークレット情報が書かれたJSONファイルがダウンロードされます。
このファイルは後で使います。
RESTfulサーバ①を立ち上げる
まずは、RESTfulサーバ①の実装を示します。
以下のnpmモジュールを使っています。
- actions-on-google
'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インテントが呼び出されます。
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/
以下のリッチレスポンスの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を自宅に住まわせる
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用に変換しています。
'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;
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コードを使うのが楽ちんです。
友達登録したのち、「シンプル」「ベーシック」「リスト」「カルーセル」「サジェスチョン」を試してみてください。Actions on GoogleとそっくりのUIが返ってくることがわかります。
以上です。