GASで自動返信と個別返信を両立したLINE botを作る。

## はじめに
こんにちは、 今回始めてAdvent Calendarに参加します @nakaaza です！
よろしくおねがいします。

さて、LINE公式アカウントはコードを書かずにOfficial Account Managerからメッセージ配信や自動応答設定をできるので大変便利です。ただ、Official Account Managerだけでは、友だちからのメッセージへの自動返信(botモード)と個別返信(チャットモード)の両立というのは僕の知る限りできません。
ただ、LINEはメッセージ操作をするAPI(LINE messaging API)が充実しているので、これを活用すれば自動返信と個別返信の両立が可能です！
なにか単発のイベントや、そこまでがっつりシステム大規模でないサービスにおけるやりとりであれば、Google Apps Script(GAS)でちゃちゃっとそんな仕組みを構築できます！
今回はそんな仕組みを紹介します。

## システムの概要
### 要件
- 友だちからの特定のメッセージに対して自動返信できる
- 友だちからのメッセージに対して、アカウントの管理者が手動で個別の内容で返信できる
    - アカウントの管理者に友だちからのメッセージ受信を公式アカウントから通知する
    - アカウントの管理者が指定の形式で公式アカウントにメッセージ送信することで、該当の友だちにメッセージ返信できる

### ざっくり仕組み
#### 自動返信
![スクリーンショット 2020-12-19 16.34.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/cf15b966-c76b-9015-dc85-81b87837b57b.png)
#### 手動返信
![スクリーンショット 2020-12-19 17.11.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/d9110835-0a80-1088-f3a5-a32d8fe2ef6c.png)




## 実装
### LINEチャネルの設定
1. [LINE Developers Console](https://developers.line.biz/console/)の「新規チャネル作成」から`Messaging API`を選択して新規チャネルを作成します(LINE Developersの登録等は割愛)。![スクリーンショット 2020-12-19 15.44.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/5d7212bc-e7b1-f327-28d2-b44c8d040bfa.png)
2. 「Messging API設定」>「チャンネルアクセストークン」からアクセストークンを発行します。![スクリーンショット 2020-12-19 15.45.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/afc1fa99-ce8b-54a0-f1e4-74d7ab06103c.png)

### Spreadsheetの設定
新規Spreadsheetに以下のシートを作成します。
- `line_user_id`、`name`カラムを持つ`users`シート
- `line_user_id`、`name`カラムを持つ`admin`シート
- `id`、`sent_at`、`line_user_id`、`sender`、`content`カラムを持つ`messages`シート

### コード
#### GASとLINE公式アカウントを連携
1.上記で作成したSpreadsheetの「ツール」>「スクリプトエディタ」からGASプロジェクトを作成します。

2.`コード.gs`に上記で取得したLINE公式アカウントのアクセストークンを記述します。

```コード.js
var ACCESS_TOKEN = '{LINE公式アカウントのアクセストークン}';
```

3.「公開」>「ウェブアプリケーションとして導入」から`Who has access to the app`を`Anyone, even anonymous`としてデプロイします(この設定をしないとLINEからのwebhook送信を受け付けることができません)。![スクリーンショット 2020-12-19 16.47.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/96982018-9b82-b17a-bfa0-e910fde90131.png)


4.デプロイ完了後に表示される`Current web app URL`に記載のURL(`https://script.google.com/macros/s/hogehoge`)を、LINE Developers Consoleの「Messaging API」>「Webhook URL」に指定します。「検証」をクリックして画像のように成功メッセージが表示されれば連携完了です！このとき「Webhookの利用」をONにすることをお忘れなく。![スクリーンショット 2020-12-19 16.54.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/ae4481e3-2860-3111-2327-4bc01cf7e0b7.png)

#### 友だち追加時の処理
LINE公式アカウントに対して何らかのアクションが行われると、上記で設定した`Webhook URL`に対してデータが`POST`され、GAS側で処理できるようになります。
GASプロジェクトに対する`POST`リクエストを受けるためには、`doPost(e)`という関数を定義します。`e`はリクエストに含まれるデータで、それを基に処理していきます。
**※ ウェブアプリケーションとして公開したGASプロジェクトは、都度バージョンを上げて再デプロイしないと、ウェブアプリケーションに最新のコードが反映されません。面倒ですが、編集して挙動を確認する際には毎回デプロイしましょう。**

```コード.js
function doPost(e) {
  // Webhookから受け取ったデータからユーザ情報(line_user_id, アカウント名)を取得します
  var event = JSON.parse(e.postData.contents).events[0];  // eventsはWebhookイベントの配列ですが、経験上複数個送られてくることはないので、0番目ののみ取得すれば十分です。
  var line_user_id = event.source.userId;
  var display_name = getDisplayName(line_user_id);
  
  var sheet_user = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('users');
  if(event.type === "follow"){  // "follow"はLINE友だち追加を示すイベントです
    var last_row = sheet_user.getLastRow();

    // 初回友だち追加時のみ、ユーザデータをusersシートに挿入(ブロック解除も`follow`イベントのため)
    if(last_row > 1){
      var users = sheet_user.getRange(2,1,last_row - 1, 1).getValues().map(e => e[0]);
    } else {
      var users = [];
    }
    if(!users.includes(line_user_id)){
      sheet_user.getRange(last_row + 1, 1, 1, 2).setValues([[line_user_id, display_name]]);
    }
  }
}

// LINEユーザのアカウント名を取得するためにLINE Messaging APIを叩きます
function getDisplayName(line_user_id){
  var url = 'https://api.line.me/v2/bot/profile/' + line_user_id;
  var res = UrlFetchApp.fetch(url, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + ACCESS_TOKEN,
    },
    'method': 'get'
  });
  return JSON.parse(res).displayName;
}
```

ここまできたら、LINE Developers ConsoleにあるQRコードから公式アカウントと友だちになります。
`users`シートにアカウント情報が追加されているはずです！![スクリーンショット 2020-12-19 17.30.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/a0d1530c-b82e-26ae-0b0c-4947b52c630d.png)
そして、アカウントの管理者にするユーザが友だちになったら、そのユーザ情報を`users`からコピーして`admin`シートに貼り付けます(複数人設定可)。メッセージの通知機能や手動返信機能はこの`admin`シートに記載のあるユーザのみ可能になります。

#### 自動返信機能
以下のような感じで、予め指定したメッセージに対しては決まった文言で返信できるようにします。

```コード.js
function doPost(e){
  // 中略
  if(event.type === "follow"){
    // 中略
  } else if(event.type === "message"){
    if(event.message.type === "text"){
      var text = event.message.text;
      switch(text){
        case "サウナ":
          var reply_token = event.replyToken;
          var messages = [{ "type": "text", "text": "ととのう" }];
          sendReplyMessage(reply_token, messages);
        case "銭湯":
          var reply_token = event.replyToken;
          var messages = [{ "type": "text", "text": "あったかい" }];
          sendReplyMessage(reply_token, messages);
      }
    }
  }
}

// あるメッセージに返信するReply APIを叩きます。Webhookイベントには必ず`replyToken`が設定されていて、それをReply APIにわたすと、即時返信ができるようになります
function sendReplyMessage(reply_token, messages){
  var url = 'https://api.line.me/v2/bot/message/reply';
  var res = UrlFetchApp.fetch(url, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + ACCESS_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': reply_token,
      'messages': messages 
    }),
  });
  return res;
}
```

できました！!
![IMG_A40FC93F4D0F-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/a27b42c5-1e4b-04b0-d80b-d9edc721d997.jpeg)

#### 手動返信機能
##### 受信したメッセージを保存し、管理者に通知する

```コード.js
function doPost(e) {
  if(event.type === "follow"){
    // 中略
  } else if(event.type === "message"){
    if(event.message.type === "text"){
      var text = event.message.text;
      switch(text){
        case "サウナ":
          // 中略
        case "銭湯":
          // 中略
        default:  // 自動応答キーワード以外のメッセージを管理者に通知します
          // 受信したメッセージを`messages`シートに保存
          var sheet_messages = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('messages');
          var now = new Date();
          var id = sheet_messages.getLastRow();
          sheet_messages.getRange(id + 1, 1, 1, 5).setValues([[id, now, line_user_id, display_name, text]]);

          // 管理者に受信したメッセージを通知
          var message = "========================\nメッセージID: " + id + "\n" + display_name + "さんからのメッセージ\n========================\n" + text;
          var messages = [{ "type": "text", "text": message }];
          sendToAdmin(messages);
      }
    }
  }
}

// 特定したユーザへメッセージを送信するPush APIを叩きます
function sendPushMessage(line_user_id, messages){
  var url = 'https://api.line.me/v2/bot/message/push';
  var res = UrlFetchApp.fetch(url, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + ACCESS_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'to': line_user_id,
      'messages': messages 
    }),
  });
  return res;
}

// 管理者を抽出します
function getAdmin(){
  var sheet_admin = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('admin');
  var admin = sheet_admin.getRange(2, 1, sheet_admin.getLastRow() - 1, 1).getValues().map(a => a[0]);
  return admin;
}

// 管理者全員にメッセージを送信します
function sendToAdmin(messages){
  var admin = getAdmin();
  for(var i=0; i<admin.length; i++){
    sendPushMessage(admin[i], messages);
  }
}
```

実際にやってみるとこんな感じです。
ユーザからメッセージが届くと、`messages`シートにデータが保存され…
![スクリーンショット 2020-12-19 20.18.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/ff690e13-f404-5fbd-d795-f42ba935ccb6.png)
管理者に公式アカウントから通知メッセージが届きました！
![IMG_1C7A42DA726D-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/4fc231fa-2afd-eef2-9ab6-1333e0e274a0.jpeg)


#### 特定のメッセージに対して管理者から個別返信する
このシステムでは管理者もユーザと同じくLINEアプリの公式アカウントをインターフェースとして使います。そのためあるメッセージに個別に返信するためには、何らかの形で「どのメッセージに返信するのか」を指定しなければいけません。
今回は、メッセージを受信した際に一意のIDを割り当てたので、それを用いることとしました。
具体的には、返信メッセージの先頭行にメッセージID(半角数字)を記入し、2行目以降に返信内容を記述することとしました。
![IMG_363808CA7D7C-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/14270388-f21d-bfd8-9e97-49095863cc62.jpeg)


```コード.js
function doPost(e) {
  if(event.type === "follow"){
    // 中略
  } else if(event.type === "message"){
    if(event.message.type === "text"){
      var text = event.message.text;
      switch(text){
        case "サウナ":
          // 中略
        case "銭湯":
          // 中略
        default: 
          var sheet_messages = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('messages');
          var admin = getAdmin();
          var message_id = text.split("\n")[0];  // メッセージを行ごとに分割して、配列に格納します
          if(admin.includes(line_user_id) && isNumber(message_id)){  // 「メッセージの送信者が管理者 かつ メッセージの1行目が数値である」ときは管理者からの返信メッセージと判定します
            var message_to_reply = find_message_by_id(message_id, sheet_messages);
            var reply_message = text.split("\n").slice(1).join("\n");  // 実際の返信メッセージには1行目のメッセージIDは不要なので削除します
            sendPushMessage(messege_to_reply[2], [{ "type": "text", "text": reply_message }]);  // 返信先のユーザに、管理者からのメッセージを送信します
          } else {  // 返信でないメッセージを処理します。前述の通りです
            // 受信したメッセージを`messages`シートに保存
            // 中略

            // 管理者に受信したメッセージを通知
            // 中略
      }
    }
  }
}

// 引数の値が数値であるかを判定します。こちらの記事を参考にさせていただきました→ https://chaika.hatenablog.com/entry/2019/12/18/083000
function isNumber(n){
  var type = typeof(n);
  if ( type === 'number' && Number.isFinite(n) ) {
    return true;
  }
  if ( type === 'string' && n.trim() !== '' && Number.isFinite(n - 0) ) {
    return true;
  }
  return false;
};


function find_message_by_id(id, sheet_messages){
  var messages = sheet_messages.getDataRange().getValues();  // 保存されたメッセージ全体を取得します
  
  // 指定したIDを持つメッセージを抽出します
  for(var i=1; i<messages.length; i++){
    if(messages[i][0] == id){
      console.log(messages[i]);
      return messages[i];
    }
  }
  return null;
}
```

すると、ユーザに返信メッセージが届きました！![IMG_622693923957-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271701/398e4177-18da-536c-e133-87c28436f690.jpeg)



LINE Messaging APIのメッセージ送信メソッドには大別して2種類あります。ひとつはそれぞれのWebhookイベントに紐づく`replyToken`を使った`Reply API`、もう一つは送信先のユーザを直接指定する`Push API`です。前者は自動応答、後者は個別返信と両者を使い分けることで、Offcial Account Managerだけではできなかった2種類のメッセージ送信方法の両立を可能にしています。


## おわりに
LINE botをGASで作ることのメリットとしては、今回の自動/手動返信の両立などOfficial Account Managerだけでは実現できない機能をもたせることができる点、環境構築が要らないので気軽にササッとできる点があります。
GASはどちらかというとGoogleの各サービスをインタラクティブに、そして拡張性高く操作するためのツールとしての印象がありますが、今回のように外部サービスと連携することでさらにその活用の幅が広がります！

今回のシステムはまだまだ中途半端な部分も多いですが、どなたかの参考になれば嬉しい限りです.

Advent Calendar当日になって慌てて書いたのでやや雑な投稿となってしまいしたが、なにかご意見・ご質問あればコメントをください:sweat:

それではみなさま、よいGASライフをお過ごしください:santa: