はじめに
2019年3月31日にクローズ予定のデータで恋するマッチングアプリ Pancyで開発をしている(た?)@cgetcです。
上記サービスのマーケティング施策で、LINE Messaging APIを利用した診断ボットを作成しました。
その際の実装について説明したいと思います。
実装について
診断データの定義について
LINE Messaging APIのメッセージオブジェクトにならってデータを定義
- 列がLINE Messaging APIのメッセージイベントオブジェクトのプロパティに対応しており、1行がオブジェクトになります。
- typeを切り替えることでオブジェクトの種類が切り替わります。
- 列を増やすことで新たに使用するプロパティを定義できます。
メッセージオブジェクトの入れ子構造を親子関係をIDで定義
- テンプレートメッセージオブジェクトなどオブジェクトの中に別のオブジェクトを定義するためにidを使用しています。
- 同じparentの値を指定すれば複数の子オブジェクトを定義できます。
答えに応じて次に送信するメッセージオブジェクトを定義
質問と答えをtemplateとpostbackで定義
GASの実装について
1. Webhookを受け取る
Google Spreadsheetに関連づいたスクリプトにdoPost
というメソッドを実装することでWebhookが受け取れます。
※ LINE Messaging APIではWebhookのリクエストヘッダに X-Line-Signature
という署名がに付加されており、それを検証する必要があるのですが、GASではリクエストヘッダを受け取れないため、その実装を省略しています。
function doPost(e) {
var events = JSON.parse(e.postData.contents).events;
events.forEach(function (event) {
doEvent(event);
});
}
イベントごとの処理はdoEvent
関数に移譲しています。
一つのアカウントで複数の診断を扱えるように、Sheetを指定します。
var START_MESSAGES = {
'恋愛ケーキ診断をはじめる♪': 'cake' // 値はSheetの名前
};
function doEvent(event) {
var data, messages;
if (event.type === 'message') {
// 特定なメッセージを受け取った場合、IDが0のレコードを取得する
data = {sheet: START_MESSAGES[event.message.text], id: 0}
} else if(event.type === 'postback') {
// 質問の答えを受け取った場合、答えのIDからレコードを取得する
data = JSON.parse(event.postback.data);
}
if (data && data.sheet) {
// sheetのデータを読み込む(後述)
load(data.sheet);
// 関連したレコードを取得し、メッセージオブジェクトを作成する(後述)
messages = getRowsByParent(data.id).map(createMessage);
// メッセージオブジェクトをLINE Messaging APIを通して返信する(後述)
reply(messages, event.replyToken);
}
}
2. Spreadsheetのデータを元にメッセージオブジェクトを作成する
createMessage
関数でtype
に応じたメッセージオブジェクトをSpreadsheetのデータを元に作成しています。
getRowsByParent
getRowById
はSpreadsheetからデータを取得するための便利関数です。
var Sheet = null;
var IDs = null;
var Col = {};
var LastIndex = -1;
// Spreadsheetの読み込む
function load(name) {
Sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(name);
// parentとidの2次元配列にする
IDs = Sheet.getSheetValues(2, 1, Sheet.getLastRow() - 1, 2);
LastIndex = Sheet.getLastColumn();
// 配列のindexを扱いやすいようにSpreadsheetの先頭行から配列アクセス用のオブジェクト作成する
Sheet.getSheetValues(1, 1, 1, LastIndex)[0].forEach(function (col, i) { Col[col] = i; })
}
// parentを元にシートのデータを取得
function getRowsByParent(parent) {
var rows = [], index = {}, start = -1;
IDs.forEach(function (row, i) {
if (row[Col.parent] == parent) {
if (start === -1) {
start = i;
index[i] = 1;
} else {
index[start]++;
}
} else {
start = -1;
}
});
for (var i in index) {
rows = rows.concat(Sheet.getSheetValues(parseInt(i, 10) + 2, 1, index[i], LastIndex));
}
return rows;
}
// idを元にシートのデータを取得
function getRowById(id) {
var index = -1;
IDs.forEach(function (row, i) {
if (row[Col.id] == id) {
index = i;
return false;
}
});
if (index >= 0) {
return Sheet.getSheetValues(index + 2, 1, 1, LastIndex)[0];
}
}
// レコードを元にメッセージオブジェクトを生成
function createMessage(row) {
var type = row[Col.type];
var msg;
switch (type) {
case 'text':
msg = {
type: type,
text: row[Col.text]
};
return msg;
case 'buttons':
msg = {
type: 'template',
altText: row[Col.altText],
template: {
type: type,
text: row[Col.text],
actions: getRowsByParent(row[Col.id]).map(createMessage)
}
};
// LINE Messaging APIのメッセージイベントオブジェクトはnullや空文字でもフィールドがあるとエラーになるので、
// レコードに定義されていない場合はフィールド自体を追加しない
if (isDefined(row[Col.thumbnailImageUrl])) msg.template.thumbnailImageUrl = row[Col.thumbnailImageUrl];
if (isDefined(row[Col.imageAspectRatio])) msg.template.imageAspectRatio = row[Col.imageAspectRatio];
if (isDefined(row[Col.imageSize])) msg.template.imageSize = row[Col.imageSize];
if (isDefined(row[Col.imageBackgroundColor])) msg.template.imageBackgroundColor = row[Col.imageBackgroundColor];
if (isDefined(row[Col.title])) msg.template.title = row[Col.title];
if (isDefined(row[Col.defaultAction])) msg.template.defaultAction = createMessage(getRowById(row[Col.defaultAction]));
return msg;
case 'action.postback':
msg = {
type: 'postback',
// 押されたオブジェクトのidとシート名を渡す
data: JSON.stringify({sheet: Sheet.getName(), id: row[Col.id]}),
label: row[Col.title]
};
if (isDefined(row[Col.text])) msg.displayText = row[Col.text];
return msg;
}
}
function isDefined(value) {
return value != null && value != '';
}
3. LINEボットで返答する
リクエストに含まれるevent
オブジェクトにあるreplyToken
と、作成したメッセージオブジェクトをAPIにリクエストするとボットがユーザに返信します。
アクセストークンなど、システム固有の値はGoogle Spreadsheetのシステムプロパティに格納しておくと、PropertiesService
APIを経由して取得できるので便利です。
var ScriptProperties = PropertiesService.getScriptProperties();
// 返信する
function reply(messages, replyToken) {
var payload = {
replyToken: replyToken,
messages: messages
};
var replyData = {
method: 'post',
headers: {
'Content-Type' : 'application/json',
'Authorization' : 'Bearer ' + ScriptProperties.getProperty('ACCESS_TOKEN')
},
payload: JSON.stringify(payload)
};
UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', replyData);
}
データの確認
定義された診断データが正しいかを確認するのは困難なので、ログを出力する関数を作成し、それを実行することで誤りがないか確認できるようにしました。
どのようなログがあると誤りを見つけやすいか、いろいろ試した結果以下のようなものに落ち着きました。
想定した件数のログが出力されていない場合は、データに不備があるのがわかります。
function test() {
Logger.log('[TEST] start.');
// LINE Message APIに問い合わせないようにreply関数をモックする
var reply = this.reply;
this.reply = function (messages, replyToken) {
messages.forEach(function(message, i) {
if (message.type === 'template') {
var events = message.template.actions.map(function (action) {
return {
type: action.type,
postback: {
data: action.data
},
// replyTokenに途中のデータを格納する
replyToken: replyToken.concat([message.altText, action.label])
};
});
events.forEach(function (event) {
doEvent(event);
});
} else if (message.type === 'text' && i === 0) {
// 最後にまとめて出力する
Logger.log(replyToken.concat([message.text]).join(' '));
}
});
};
// 主となる関数を実行する
doEvent({
type: 'message',
message: {
text: '恋愛ケーキ診断をはじめる♪'
},
replyToken: []
});
this.reply = reply;
Logger.log('[TEST] end.');
}
最後に
診断テストはフローチャートと構造は一緒(ツリー構造)なので、親IDと子IDをもたせれば、Google Spreadsheet(とその周辺技術)のみ実現できそうだったので、実際に作成してみました。
後回しになりがちなデータを登録する管理画面を用意しなくても、非エンジニアでも文言修正ができるのは便利でした。
細かなこだわりが発生しがちなUIが不要なので、コードを書くのに専念して目に見えるインタラクティブなボットが開発できたのは楽しかったです。
Google Spreadsheetの制約でできないことや、データ入力の面倒さなど課題はありますが、とりあえずボットを始めるにはよい試みだったと思います。