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

# はじめに

2019年3月31日にクローズ予定の[データで恋するマッチングアプリ Pancy](https://pancy.jp/)で開発をしている(た?)@cgetcです。
上記サービスのマーケティング施策で、LINE Messaging APIを利用した診断ボットを作成しました。
その際の実装について説明したいと思います。

実際には以下のようなものができました。
![output.gif](https://qiita-image-store.s3.amazonaws.com/0/15400/2b65a1cb-f159-b482-f595-dabd36d38906.gif)

# 実装について

概要は下図のとおりです。
![Architecture - Content Management.png](https://qiita-image-store.s3.amazonaws.com/0/15400/24c6ea01-9559-a062-d2dd-a310c1cbcc0e.png)

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

- 列が[LINE Messaging APIのメッセージイベントオブジェクト](https://developers.line.me/ja/reference/messaging-api/#message-event)のプロパティに対応しており、１行がオブジェクトになります。
- typeを切り替えることでオブジェクトの種類が切り替わります。
- 列を増やすことで新たに使用するプロパティを定義できます。
![カラム.png](https://qiita-image-store.s3.amazonaws.com/0/15400/446e8ec0-f70a-7ae5-1174-733b31d9c54c.png)

### メッセージオブジェクトの入れ子構造を親子関係をIDで定義
- [テンプレートメッセージオブジェクト](https://developers.line.me/ja/reference/messaging-api/#anchor-b920b618f88811c039983809a42a46480e8f237f)などオブジェクトの中に別のオブジェクトを定義するためにidを使用しています。
- 同じparentの値を指定すれば複数の子オブジェクトを定義できます。
![親子.png](https://qiita-image-store.s3.amazonaws.com/0/15400/a79bac4e-f7cb-fad5-623b-1e241921dd99.png)

### 答えに応じて次に送信するメッセージオブジェクトを定義
- postbackメッセージの`data`に自身のオブジェクトの`id`を指定します。
- 受け取った`id`をもとに次に送信するオブジェクトを特定します。
![次.png](https://qiita-image-store.s3.amazonaws.com/0/15400/942f19bb-6d8f-3df5-2220-99a5fd192378.png)

### 質問と答えをtemplateとpostbackで定義
- 答えのボタンを押すと次の質問が表示されます。
![postback.png](https://qiita-image-store.s3.amazonaws.com/0/15400/bfdd7cdb-b746-4852-cdca-3e0c73a1a01b.png)



## GASの実装について

### 1. Webhookを受け取る
Google Spreadsheetに関連づいたスクリプトに`doPost`というメソッドを実装することでWebhookが受け取れます。
※　LINE Messaging APIではWebhookのリクエストヘッダに `X-Line-Signature` という署名がに付加されており、それを検証する必要があるのですが、GASではリクエストヘッダを受け取れないため、その実装を省略しています。

```JS
function doPost(e) {
  var events = JSON.parse(e.postData.contents).events;
  events.forEach(function (event) {
    doEvent(event);
  });
}
```

イベントごとの処理は`doEvent`関数に移譲しています。
一つのアカウントで複数の診断を扱えるように、Sheetを指定します。

```js

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からデータを取得するための便利関数です。

```JS
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を経由して取得できるので便利です。

```js
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);
}

```

## データの確認

定義された診断データが正しいかを確認するのは困難なので、ログを出力する関数を作成し、それを実行することで誤りがないか確認できるようにしました。
どのようなログがあると誤りを見つけやすいか、いろいろ試した結果以下のようなものに落ち着きました。
想定した件数のログが出力されていない場合は、データに不備があるのがわかります。

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