LINE Messaging API + GAS + Googleスプレッドシートで集計Botを自作する

LINE Messaging APIを使って、メンバーの言った数字を足し合わせてくれる「集計さんbot」を作ってみました。

# 完成品のイメージ
<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/9277e2d1-5136-e7d5-80ba-baf2e2da0ada.png">

## 使い方
1. グループやトークルームに「集計さんbot」を招待する
2. あいことば「集計開始」を送って、「集計さんbot」を呼び出す
3. 「集計さんbot」を呼び出せたら、みんなで数字を送る
4. 数字を送り終えたら、あいことば「集計終了」を送る
5. すると「集計さんbot」が集計を返してくれた後、そのまま帰っていく

# 使うもの
- LINE Messaging API（無料）
- Google Apps Script（無料）
- Googleスプレッドシート（無料）

# Messaging APIでBotアカウントを作る
## 1. LINE Developersで新規プロバイダーを作成
https://developers.line.biz/ja/
<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/00c21813-b346-7564-8e64-ac3a0eda3237.png">

## 2. 新規チャネルを作成
Messaging APIを選択します。ここで作成する１チャネルが１Botアカウントになります。
<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/7ebfa390-819c-ad8f-ac4d-a66abd18f366.png">

## 3. 作成したチャネルの初期設定
必須項目を入力して、利用規約に同意したら、「作成」ボタンを押します。
<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/3af80411-e935-4451-7dd5-35b74427a61c.png">

## 4. できました＼(^_^)／
<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/3cdc02a6-edbf-28be-2c7d-6a22e7f9e4b8.png">


# GAS（Google Apps Script）でサーバー構築
## 1. Google Driveからスクリプトファイルを新規作成
<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/e3c7fccb-e9e3-5ecc-e006-be8fda91e67a.png">

## 2. Messaging APIと連携する
まずは送信されたメッセージをオウム返しするものを作ります。
<img width=800 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/bc1e79f4-c095-3ede-41ee-da4d1d2665dd.png">

### 2.1 アクセストークンを発行
- LINE DevelopersからMessaging API設定を開いて、アクセストークンを発行します。
<img width=800 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/31fec37f-4dbd-5fd0-a1dd-de5dbb2fce7a.png">
<img width=800 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/23c707a8-7e13-be9c-a60c-e30195c01d3f.png">

### 2.2 GASを編集
- 生成したアクセストークンをスクリプトに貼りつけます。
- `replyMessage`メソッドで、特定の応答トークンに対しテキストメッセージを送信します。→[公式リファレンス](https://developers.line.biz/ja/docs/messaging-api/sending-messages/#reply-messages)

```js
// チャネルアクセストークン
const ACCESS_TOKEN = "ここへアクセストークン";
const HEADERS = {
  "Content-Type": "application/json; charset=UTF-8",
  "Authorization": "Bearer " + ACCESS_TOKEN
};

// メッセージを返信する
function replyMessage(replyToken, message) {
  let url = "https://api.line.me/v2/bot/message/reply";
  let postData = {
    "replyToken": replyToken,
    "messages": [{
      "type": "text",
      "text": message
    }]
  };
  let options = {
    "method": "POST",
    "headers": HEADERS,
    "payload": JSON.stringify(postData)
  };
  
  return UrlFetchApp.fetch(url, options);
}

// 受け取ったメッセージに対して返信する
function doPost(e) {
  let webhookData = JSON.parse(e.postData.contents).events[0];
  let replyToken = webhookData.replyToken;
  let message = webhookData.message.text;

  // オウム返し
  return replyMessage(replyToken, message);
}
```

### 2.3 GASサーバーをLINEのwebhookイベントを処理するエンドポイントとして設定
- GAS上で、公開 > ウェブアプリケーションとして導入 をクリック
- "Who has acsess to the app"の項目を`Anyone, even anonymous`に設定します。
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/44dce869-0452-4691-2748-76f7b9aae927.png">

- セキュリティメッセージが表示されても、詳細を表示して許可します。
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/c6273bdf-c958-429f-38d1-abcc92ce0b44.png">

- 生成されたURLをコピーします。
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/48acd752-6a03-e213-fb74-8f01b7e589b6.png">

- LINE Developers > Messaging API設定から"Webhook URL"へコピーしたURLを貼りつけます。
<img width=800 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/34f77863-1e79-aa87-5308-16862756213c.png">

### 2.4 Botをフォローして動作確認
- 最後にOfficial Account Managerからデフォルトの応答メッセージをオフにします。
<img width=800 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/3159cea4-6ce8-a4f7-f629-323884b6e130.png">
以上の設定ができれば、オウム返しBotとして動作すると思います。


# 集計Botを作る
## 1. アーキテクチャ設計
このBotは集計するのが目的なので、送られてきたメッセージに対して返信するだけでなく、前回までの総和を覚えておかなければなりません。GASは基本的にステートレスなサーバーなのでセッションを保持することができません。そこでGoogleスプレッドシートの出番です。
<img width=800 name="3layer.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/dfa9e905-c4db-ec7e-245b-9a1aac4c74d7.jpeg">

３層アーキテクチャでいうアプリサーバ層をGASが担当し、データベース層はGoogleスプレッドシートに担当させます。

## 2. データベース設計
チャット毎に集計結果を保持しなければいけないので、なんらかのかたちで一意なセッションIDを割りふる必要があります。ありがたいことにLINE Message APIにはuserId、groupId、roomIdを取得する機能があるので、これを利用します。

例えばトークルームであれば、このように発言した人のuserId（"U"始まり）とトークルームのroomId（"R"始まり）が取得できます。groupIdはundefinedが返ります。
<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/3fac855b-e301-42e3-e4ad-05aded962584.png">

ここから一意なセッションID（=チャットID）を次のように定義します。

```js
// チャットIDを返す
function getChatId(webhookData) {
  let userId = webhookData.source.userId;
  let groupId = webhookData.source.groupId;
  let roomId = webhookData.source.roomId;

  if (typeof roomId != "undefined") {
    // トークルームであればroomIdを返す
    return roomId;
  } else if (typeof groupId != "undefined") {
    // グループであればgroupIdを返す
    return groupId;
  } else {
    // 個チャであればuserIdを返す
    return userId;
  }
}
```

### Googleスプレッドシートでの実現
Google スプレッドシートでは、このセッションIDをシート名にして、１シートに１チャットルームの集計情報が格納されているようにします。

### 2.1 スプレッドシート
スプレッドシートを開いてURLの囲んだ部分が、スプレッドシートIDになります。使用するセルはA1、B1、C1の３ヶ所のみです。
<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/cdf90965-0583-27ef-61c1-229fa90360a3.png">

### 2.2 GASから新規シートを追加
新規シートを追加したあと、そこへ初期データ（A1、B1、C1それぞれへ"0"）をセットします。

```js
// Googleスプレッドシート
const SS_ID = "ここへスプレッドシートID";
const SS = SpreadsheetApp.openById(SS_ID);

// 新規シートを追加
function createNewSheet(sheetName) {
  let newSheet = SS.insertSheet(sheetName);
  // 初期データをセット
  newSheet.getRange(1, 1).setValue("0");
  newSheet.getRange(1, 2).setValue("0");
  newSheet.getRange(1, 3).setValue("0");
}
```

### 2.3 シートが存在しない場合、新規シートを作成
チャットIDの名前のシートがスプレッドシート内にまだ存在しなければ、そのチャットIDをシート名にした新規シートを作成します。

```js
// 指定したシート名のシートの存在を返す
// あればシート番号、なければ-1
function checkIfSheetExists(sheetName) {
  // スプレッドシートにある全てのシートを取得
  let sheets = SS.getSheets();
  // 検索
  for (let i=0; i<sheets.length; i++) {
    if (sheets[i].getName() == sheetName) {
      return i;
    }
  }
  return -1;
}

function doPost(e) {
  let webhookData = JSON.parse(e.postData.contents).events[0];
  let chatId = getChatId(webhookData);

  // チャットIDの名前のシートが存在しなければ新規シート作成
  if (checkIfSheetExists(chatId) == -1) {
    createNewSheet(chatId);
  }
}
```


## 3. アプリ設計
Botの設計として、普段は来るときに備えて眠っててもらいます。「集計開始」というメッセージに目を覚ましてやってくる→数字を送ると集計してくれる→「集計終了」が送られてくると速やかに休止状態になる、という流れです。全体を状態遷移図として表すと下のようになります。
<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/1a531d08-659c-d413-bdad-d367217e8d81.jpeg">

### 各メソッド
ここから各状態遷移を表すメソッドのスクリプトを見ていきます。スプレッドシートで持つ情報としては以下の通りです。

|セル|格納情報|
|:----:|:------|
|A1|ステータス（待機中0、集計中1）|
|B1|現時点でのカウント|
|C1|現時点での総和|

集計中...
<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/521914/5acd98d3-8819-6131-2e5b-54daf8bca85d.png">

### 3.1 集計開始
A1へ"1"をセットすることで、ステータスが集計中になります。

```js
function prepareSheet(sheetName) {
  let sheet = SS.getSheetByName(sheetName);
  // 集計中のフラグを立てる
  sheet.getRange(1, 1).setValue("1");
  sheet.getRange(1, 2).setValue("0");
  sheet.getRange(1, 3).setValue("0");
}
```

### 3.2 集計実行
送られてきた数字をもとにB1、C1の値を更新します。

```js
function executeSumSheet(sheetName, number) {
  let sheet = SS.getSheetByName(sheetName);
  // B1、C1の値を取得
  let count = Number(sheet.getRange(1, 2).getValue());
  let sum = Number(sheet.getRange(1, 3).getValue());
  
  count += 1;
  sum += Number(number);
  // 値を更新
  sheet.getRange(1, 2).setValue(count);
  sheet.getRange(1, 3).setValue(sum);
}
```

### 3.3 集計結果
現時点での総和と平均を返します。

```js
function returnSumSheet(sheetName) {
  let sheet = SS.getSheetByName(sheetName);
  
  let count = Number(sheet.getRange(1, 2).getValue());
  let sum = Number(sheet.getRange(1, 3).getValue());
  let mean = sum / count;
  let mean_round = Math.round(mean * 1000) / 1000;
  
  let reply = "【集計結果】\n" + "[カウント] " + count + "\n[合計] " + sum + "\n[平均] " + mean_round;
  return reply;
}
```

### 3.4 集計終了
A1を"0"にリセットすることで、ステータスが待機中になります。

```js
function finishSheet(sheetName) {
  let sheet = SS.getSheetByName(sheetName);
  // ステータスを待機中にして、データをリセット
  sheet.getRange(1, 1).setValue("0");
  sheet.getRange(1, 2).setValue("0");
  sheet.getRange(1, 3).setValue("0");
}
```

### 3.5 メイン処理
上記に加えて、正規表現で数字判定を行っています。これにより送られてきたのが数字の場合は集計実行メソッドに値を渡し、数字でない場合はエラーメッセージを返します。

```js
// 数字判定（0以上の整数のみ）
function isNumber(val) {
  let regexp = new RegExp(/^([1-9]\d*|0)$/);
  return regexp.test(val);
}

// ステータスを返す
function getStatusSheet(sheetName) {
  let sheet = SS.getSheetByName(sheetName);
  return Number(sheet.getRange(1, 1).getValue());
}

function doPost(e) {
  let webhookData = JSON.parse(e.postData.contents).events[0];
  let replyToken = webhookData.replyToken;
  let message = webhookData.message.text;
  
  let chatId = getChatId(webhookData);
  
  // チャットIDの名前のシートが存在しなければ新規シート作成
  if (checkIfSheetExists(chatId) == -1) {
    createNewSheet(chatId);
  }

  //=== 集計 ===//
  let syukeiPrefix = "== 集計さんbot ==\n\n";
  
  //=== 集計中 ===//
  // 集計ステータスが1（オン）になっていたら集計する
  if (getStatusSheet(chatId) == 1) {
    
    //=== 集計終了 ===//
    if (message == "集計終了") {
      // 集計結果を返し、集計ステータスを0（オフ）にする
      let reply = returnSumSheet(chatId);
      finishSheet(chatId);
      reply += "\n\n【集計終了】\nまた集計したいものがあったら、「集計開始」で呼んでください！";
      return replyMessage(replyToken, syukeiPrefix + reply);
    }
    
    //=== 集計結果 ===//
    if (message == "集計結果") {
      // 集計結果を返す
      let reply = returnSumSheet(chatId);
      return replyMessage(replyToken, syukeiPrefix + reply);
    }
    
    let reply = "【集計中】\n";
    if (isNumber(message)) {
      // 入力されたテキストが整数のとき
      reply += "覚えました！" + message;
      executeSumSheet(chatId, message);
      return replyMessage(replyToken, syukeiPrefix + reply);
    } else {
      // 入力されたテキストが整数ではないとき
      reply += "[エラー] 数字だけを入力してください";
      return replyMessage(replyToken, syukeiPrefix + reply);
    }
  }
  
  //=== 集計開始 ===//
  if (message == "集計開始") {
    let reply = "準備が完了しました！\n数字を入力してください。";
    // 集計の準備
    prepareSheet(chatId);
    
    return replyMessage(replyToken, syukeiPrefix + reply);
  }
}
```


## 4. 全体像
```js
// チャネルアクセストークン
const ACCESS_TOKEN = "ここへアクセストークン";
const HEADERS = {
  "Content-Type": "application/json; charset=UTF-8",
  "Authorization": "Bearer " + ACCESS_TOKEN
};

// Googleスプレッドシート
const SS_ID = "ここへスプレッドシートID";
const SS = SpreadsheetApp.openById(SS_ID);


//================================//
//================================//

// メッセージを返信する
function replyMessage(replyToken, message) {
  let url = "https://api.line.me/v2/bot/message/reply";
  let postData = {
    "replyToken": replyToken,
    "messages": [{
      "type": "text",
      "text": message
    }]
  };
  let options = {
    "method": "POST",
    "headers": HEADERS,
    "payload": JSON.stringify(postData)
  };
  
  return UrlFetchApp.fetch(url, options);
}

// チャットIDを返す
function getChatId(webhookData) {
  let userId = webhookData.source.userId;
  let groupId = webhookData.source.groupId;
  let roomId = webhookData.source.roomId;

  if (typeof roomId != "undefined") {
    return roomId;
  } else if (typeof groupId != "undefined") {
    return groupId;
  } else {
    return userId;
  }
}


//================================//
//================================//

// 数字判定（0以上の整数のみ）
function isNumber(val) {
  let regexp = new RegExp(/^([1-9]\d*|0)$/);
  return regexp.test(val);
}

// 指定したシート名のシートの存在を返す
// あればシート番号、なければ-1
function checkIfSheetExists(sheetName) {
  // スプレッドシートにある全てのシートを取得
  let sheets = SS.getSheets();
  // 検索
  for (let i=0; i<sheets.length; i++) {
    if (sheets[i].getName() == sheetName) {
      return i;
    }
  }
  return -1;
}

// 新規シートを追加
function createNewSheet(sheetName) {
  let newSheet = SS.insertSheet(sheetName);
  // 初期データをセット
  newSheet.getRange(1, 1).setValue("0");
  newSheet.getRange(1, 2).setValue("0");
  newSheet.getRange(1, 3).setValue("0");
}

// 集計の準備
function prepareSheet(sheetName) {
  let sheet = SS.getSheetByName(sheetName);
  // 集計中のフラグを立てる
  sheet.getRange(1, 1).setValue("1");
  sheet.getRange(1, 2).setValue("0");
  sheet.getRange(1, 3).setValue("0");
}

// 集計の終了
function finishSheet(sheetName) {
  let sheet = SS.getSheetByName(sheetName);
  // ステータスを待機中にして、データをリセット
  sheet.getRange(1, 1).setValue("0");
  sheet.getRange(1, 2).setValue("0");
  sheet.getRange(1, 3).setValue("0");
}

// ステータスを返す
function getStatusSheet(sheetName) {
  let sheet = SS.getSheetByName(sheetName);
  return Number(sheet.getRange(1, 1).getValue());
}

// 集計実行メソッド
function executeSumSheet(sheetName, number) {
  let sheet = SS.getSheetByName(sheetName);
  
  let count = Number(sheet.getRange(1, 2).getValue());
  let sum = Number(sheet.getRange(1, 3).getValue());
  
  count += 1;
  sum += Number(number);
  sheet.getRange(1, 2).setValue(count);
  sheet.getRange(1, 3).setValue(sum);
}

// 総和を返す
function returnSumSheet(sheetName) {
  let sheet = SS.getSheetByName(sheetName);
  
  let count = Number(sheet.getRange(1, 2).getValue());
  let sum = Number(sheet.getRange(1, 3).getValue());
  let mean = sum / count;
  let mean_round = Math.round(mean * 1000) / 1000;
  
  let reply = "【集計結果】\n" + "[カウント] " + count + "\n[合計] " + sum + "\n[平均] " + mean_round;
  return reply;
}


//================================//
//================================//

// 受け取ったメッセージに対して返信する
function doPost(e) {
  let webhookData = JSON.parse(e.postData.contents).events[0];
  let replyToken = webhookData.replyToken;
  let message = webhookData.message.text;

  let chatId = getChatId(webhookData);

  // チャットIDの名前のシートが存在しなければ新規シート作成
  if (checkIfSheetExists(chatId) == -1) {
    createNewSheet(chatId);
  }

  //=== 集計 ===//
  let syukeiPrefix = "== 集計さんbot ==\n\n";

  //=== 集計中 ===//
  // 集計ステータスが1（オン）になっていたら集計する
  if (getStatusSheet(chatId) == 1) {

    //=== 集計結果 ===//
    if (message == "集計結果") {
      // 集計結果を返す
      let reply = returnSumSheet(chatId);
      return replyMessage(replyToken, syukeiPrefix + reply);
    }

    //=== 集計終了 ===//
    if (message == "集計終了") {
      // 集計結果を返し、集計ステータスを0（オフ）にする
      let reply = returnSumSheet(chatId);
      finishSheet(chatId);
      reply += "\n\n【集計終了】\nまた集計したいものがあったら、「集計開始」で呼んでください！";
      return replyMessage(replyToken, syukeiPrefix + reply);
    }

    let reply = "【集計中】\n";
    if (isNumber(message)) {
      // 入力されたテキストが整数のとき
      reply += "覚えました！" + message;
      executeSumSheet(chatId, message);
      return replyMessage(replyToken, syukeiPrefix + reply);
    } else {
      // 入力されたテキストが整数ではないとき
      reply += "[エラー] 数字だけを入力してください";
      return replyMessage(replyToken, syukeiPrefix + reply);
    }
  }

  //=== 集計開始 ===//
  if (message == "集計開始") {
    // 集計の準備
    prepareSheet(chatId);
    let reply = "準備が完了しました！\n数字を入力してください。";
    return replyMessage(replyToken, syukeiPrefix + reply);
  }
}
```


# おわりに
休日のたわむれにLINE Botを作ってみました。GASとGoogleスプレッドシートを使うやり方は記事も多く、比較的取り組みやすかったです。なによりも全て無料でできてしまうというのが驚きです。
今回は「集計する」という最もシンプルな機能でしたが、基本的には同じ設計で読書管理Botや家計簿Botのようなものも作れると思います。スプレッドシートのシート名をセッションIDにしてしまおう、という発想はどんな機能でも応用が利くかと思うので、皆さんも独自性を生かしたBotを作ってみてください！