Edited at

Google Spreadsheetで診断ボットをつくった


はじめに

2019年3月31日にクローズ予定のデータで恋するマッチングアプリ Pancyで開発をしている(た?)@cgetcです。

上記サービスのマーケティング施策で、LINE Messaging APIを利用した診断ボットを作成しました。

その際の実装について説明したいと思います。

実際には以下のようなものができました。


実装について

概要は下図のとおりです。


診断データの定義について


LINE Messaging APIのメッセージオブジェクトにならってデータを定義


  • 列がLINE Messaging APIのメッセージイベントオブジェクトのプロパティに対応しており、1行がオブジェクトになります。

  • typeを切り替えることでオブジェクトの種類が切り替わります。

  • 列を増やすことで新たに使用するプロパティを定義できます。



メッセージオブジェクトの入れ子構造を親子関係をIDで定義


答えに応じて次に送信するメッセージオブジェクトを定義


  • postbackメッセージのdataに自身のオブジェクトのidを指定します。

  • 受け取ったidをもとに次に送信するオブジェクトを特定します。



質問と答えを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のシステムプロパティに格納しておくと、PropertiesServiceAPIを経由して取得できるので便利です。

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の制約でできないことや、データ入力の面倒さなど課題はありますが、とりあえずボットを始めるにはよい試みだったと思います。