LINE Messenger APIの「クイックリプライ」と「ポストバックアクション」を使ってチャットからSpreadsheetの記録を更新

# 前書き
この記事は連作で、ラン記録をシェアし、メンバーの走行記録を集計する活動を、LINE Botで自動化しようとしている取り組みです。

Cloud Vision APIでテキスト検出できることから、目で読んで手で打つという手間を省けないかと取り組み始めました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3381/f16bf166-12fc-46d0-a414-3e7a07bb626d.png)
現在は作ったBotを加えたLINEグループにて、メンバーが上げてくれるラン画像で見つかる考慮漏れなどを修正するなど、日々小さく改善を繰り返しております。

[前回の記事](https://qiita.com/quwaji/items/a9ee4ea4375d84323ccb)でのTODOはこんな感じでした。

- 集計はgroupIdまたはuserId単位に行う
- 記録が間違っていた時に編集削除できる
- 新しいユーザがJoinしたらUser Listに追加
- 検出された候補（距離・タイム）が複数あった場合にLINEのクイックリプライとか使えるか検討
- Cloud Vision APIの使用状況確認と課金への対応検討
> 記録の訂正削除、名前の設定ができればメンテフリーになりそう。もう一息だ！

上記に取り組み、そのまま形になったものもあれば知見を得て違う形になったものもあるのですが、前回達成した「集計」に加えて、「追加」「修正」「取り消し」ができるようになりました！

今回の記事では、LINE Messenger APIの「クイックリプライ」と「ポストバックアクション」を使ってこれらの実装を行なったことをまとめます。

# LINEアプリでは返信が可視化されるのに。。
まず運用していて課題なのは、修正や追記、次いで削除でした。
修正は、どの記録を修正するのか対象を特定する必要があります。LINEのチャットを使った流れで自然だと感じたのは、返信を使うことでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3381/13d3a094-78c4-61ee-d234-d4ce4eba8671.png)
しかし、LINE Messenger APIでは、Webhookで渡される情報やAPIを調べても、返信先を特定する方法は見当たりませんでした。アプリは非公開のAPIを使っているのではないかと思います。
また、できたとしても、長押しして「返信」を選んで「修正」と書く、なんて、できる人とできない人がいそうです。これは筋悪と断念。
# クイックリプライを試す
画像を認識した際、「距離」と「タイム」のいずれかが認識されれば、他方を？？と表示してチャットに返すようにしました。シェアしてくれたメンバーにもうまくいったのか分からないからです。同時に、シートにも記録を行うことで修正の手間が少なくなるようにしていました。
LINE Messaging APIには、選択することで簡単にアクションさせられる「クイックリプライ」という機能があります。

クイックリプライを使う
https://developers.line.biz/ja/docs/messaging-api/using-quick-reply/

これを使って、？？って表示された画像が修正すべきものか削除するべきものかを教えてもらうようにしてみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3381/386b6225-c66a-16ac-4718-f8eb627d9ea2.png)
クイックリプライは、LINE APIでメッセージを送信（send/reply）する際に、下記のように要素を追加します。

```js:コード.gs
function replyLineCorrect(replyToken, strMessage, name, duration, distance){

  var tag = replyToken.substr(0,7);
  var fillInText = '';
  fillInText += `${tag}:修正\n氏名\t${name}`;
  fillInText += `\n距離\t${distance != null ? distance : '0.00'}`;
  fillInText += `\nタイム\t${duration != null ? duration : '0:00:00'}`;

  //Lineに送信するためのトークン
  var strToken = CHANNEL_ACCESS_TOKEN;
  var options =
   {
     "method"  : "post",
     "payload" : JSON.stringify({
       'messages': [{
         'type': 'text',
         'text': strMessage,
         'quickReply': {
           'items': [
              {
                'type': 'action', 
                'action': {
                  'type': 'postback',
                  'data': `${tag}:修正`,
                  'label': '修正',
                  'displayText': '記録を修正します。',
                  "inputOption": "openKeyboard",
                  "fillInText": fillInText
                }
              },
              {
                'type': 'action', 
                'action': {
                  'type': 'postback',
                  'data': `${tag}:取り消し`,
                  'label': '取り消し',
                  'displayText': '記録を取り消します。'
                }
              },
            ]
         }
        }],
       'replyToken' : replyToken,
      }),
     "headers" : {"Authorization" : "Bearer " + strToken, 
       "Content-Type" : "application/json"
     }
   };
 
   UrlFetchApp.fetch("https://api.line.me/v2/bot/message/reply",options);
}
```
このfunctionは、距離かタイムが？？だった場合に呼び出すもので、クイックリプライ付きでリプライを送ります。
messageに追加しているquickReply要素は、itemsで13個まで選択肢とそのアクションを指定できます。
[アクション](https://developers.line.biz/ja/docs/messaging-api/using-quick-reply/#action)は、カメラが起動する「カメラアクション」や、URLを開く「URIアクション」などがありますが、今回は「ポストバックアクション」を利用しました。

# まずは取り消し
「取り消し」のアクションから解説します。itemの要素は以下のようになっています。
```js:コード.gs
              {
                'type': 'action', 
                'action': {
                  'type': 'postback',
                  'data': `${tag}:取り消し`,
                  'label': '取り消し',
                  'displayText': '記録を取り消します。'
                }
              },
```
labelは選択肢に表示されるラベル、displayTextは、アクションが選択されたときにチャットに送信されるテキストです。選択したユーザの発言として表示されます。

dataが、ポストバックアクションとメッセージアクションの大きな違いで、ポストバックアクションは、webhookが呼び出されて、このdataを受け取ることができます。

tagは、functionの先頭でセットしている対象を特定するための情報で、Webhookで受け取る情報のうち、（最大５件、複数含まれることもある）イベントごとに渡されているReplyTokenの先頭7文字を使っています。
```js
  var tag = replyToken.substr(0,7);
```
これでスプレッドシートに記録した情報を検索し、その対象を処理します。
全体の流れは以下のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3381/5d3ba2e4-87f4-d998-3474-60bd8ce58253.png)
「記録を取り消します」は、ユーザの発信、これに対してポストバックでスプレッドシートを更新したら、「記録を取り消しました」とBotが返すようにしています。依頼と対応の経過が第三者：グループメンバーにも明瞭に伝わりますね。

# 修正にはinputOptionとfillInText

「修正」は、何をどう修正するのかの受け渡しが必要になります。「日時選択アクション」では、選択された日時がポストバックのdataでわかりますが、このケースではどうすればいいでしょうか。

あまり遷移や状態が多くなると使う人も分かりづらいし作る方も複雑になります。ここは、「ナイスラン！」表示したテキストをそのまま修正してもらう戦略を取りました。
```js:コード.gs
              {
                'type': 'action', 
                'action': {
                  'type': 'postback',
                  'data': `${tag}:修正`,
                  'label': '修正',
                  'displayText': '記録を修正します。',
                  "inputOption": "openKeyboard",
                  "fillInText": fillInText
                }
              },
```
inputOptionのopenKeyboardがあると、LINEのチャットが文字入力状態になります。
その際、fillInTextがあらかじめ入力された状態になっています。

これを修正してねという形に整形したテキストを表示し、名前、距離、タイムの必要な箇所を修正し、そのまま送信してもらうことで、修正内容を受け取るようにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3381/396d220d-9eeb-fe55-75a0-daf6ec5957a5.png)
意味不明な7文字がグループチャットに晒されてしまうわけですが、これは許容範囲でしょう。今のところメンバーからもネガティブな反応はありません。
# 追加も同じ流れで
ポストバックアクションを使うには、何らかのメッセージ送信とリプライが必要です。手動で記録を追加したいとき、どのような流れでできるだろうかと考えました。
「追加」と打ってもらって、いきなり「修正」と同じ動きでテンプレートが入力された状態の文字入力が開けば良いのですが、どうしても間にもう１アクションしてもらう必要がありました。やむなし。

できるだけ自然なやりとりになるようにと、最初に案内の説明と「追加」のクリックリプライ表示を出し、タップしてもらうと入力が開く流れにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3381/7698951d-2272-e189-4297-302de00eb92b.png)
# Webhookのいま
機能を拡張してきて、現在のWebhookは以下のようになっております。
```js:コード.gs
function doPost(e) {

  Logger.log('webhook received: ' + e.postData.contents);
  recordRequest(e);

  var requestObj = JSON.parse(e.postData.contents);
  if(requestObj.events.length == 0) {
    return JSON.stringify({});
  }

  for(i=0; i < requestObj.events.length; i++) {
    var event = requestObj.events[i];
    var userId = event.source.userId;
    var type = event.source.type;
    var sourcename = '';
    var groupId = '';
    var replyTo = '';
    if(type == 'group') {
      groupId = event.source.groupId;
      replyTo = groupId;
    }
    else {
      replyTo = userId;
    }
    var messageText = '';
    var replyToken = '';
    var eventType = event.type;
    switch(eventType) {
      case 'postback':
        data = event.postback.data;
        replyToken = event.replyToken;
        a = data.match(/^([0-9a-f]{7}):取り消し/);
        if(a != null){
          replyLine(replyToken, ignoreResult(a[1]));
          break;
        }
        a = data.match(/^([0-9a-f]{7}):修正/);
        if(a != null){
          replyLine(replyToken, '修正箇所を編集して、そのまま送信して下さい。');
          break;
        }
        a = data.match(/^([0-9a-f]{7}):追加/);
        if(a != null){
          replyLine(replyToken, '追加する記録を編集して、そのまま送信して下さい。');
        }
        break;
      case 'message':
        var msgType = event.message.type;
        switch(msgType) {
          case 'text':
            messageText = event.message.text;
            replyToken = event.replyToken;
            if(messageText.trim().match(/^集計[\!]*/)) {
              replyLine(replyToken, getSummary(replyTo));
              break;
            }
            replyToken = event.replyToken;
            if(messageText.trim().match(/^昨日の集計[\!]*/)) {
              replyLine(replyToken, getPreviousSummary(replyTo));
              break;
            }
            if(messageText.trim().match(/^修正[\!]*/)) {
              replyUpdateResultInstruction(replyToken, replyTo);
              break;
            }
            a = messageText.match(/^([0-9a-f]{7}):修正\n氏名\s(.*)\n距離\s([0-9]+\.[0-9]+)\nタイム\s([0-1]+:[0-5][0-9]:[0-5][0-9])/);
            if(a != null){
              // 修正
              replyLine(replyToken, updateResult(a[1], a[2], a[3], a[4]));
              break;
            }
            if(messageText.trim().match(/^追加[\!]*/)) {
              replyAddResultInstruction(replyToken);
              break;
            }
            a = messageText.match(/^([0-9a-f]{7}):追加\n氏名\s(.*)\n距離\s([0-9]+\.[0-9]+)\nタイム\s([0-1]+:[0-5][0-9]:[0-5][0-9])/);
            if(a != null){
              // 追加
              replyLine(replyToken, addResult(a[1], a[2], groupId, a[3], a[4]));
              break;
            }
            if(messageText.trim().match(/^取り消し[\!]*/)) {
              replyIgnoreResultInstruction(replyToken, replyTo);
              break;
            }
            break;
          case 'image':
            var messageId = event.message.id;
            replyToken = event.replyToken;
            // コンテンツをGETして解析、ラン画像なら返信。
            var image = getContent(messageId);
            var obj = analyzeImage(image);
            var result = obj.responses[0].textAnnotations[0].description;
            var duration = detectTime(result);
            var distance = detectDistance(result);
            Logger.log('image analyzed: ' + result + String.fromCharCode(10) + 'distance: ' + distance + ', duration: ' + duration);
            recordResult(event, result, JSON.stringify(obj), distance, duration);
            if(duration != null || distance != null) {
              messageText = 'ナイスラン！' + String.fromCharCode(10);
              messageText += '距離' + String.fromCharCode(9) + (distance != null ? distance : '??') + String.fromCharCode(10);
              messageText += 'タイム' + String.fromCharCode(9) + (duration != null ? duration : '??');
              if(duration != null && distance != null) {
                replyLine(replyToken, messageText);
              } else {
                // 距離またはタイムが不明な場合、クイックリプライ付き
                var name = getListedUserName(userId);
                replyLineCorrect(replyToken, messageText, name, duration, distance);
              }
            }
            break;
          default:
            break;
        }
      default:
        break;
    }
  }

  return JSON.stringify({});
}
```

# トライアンドエラーの数々
日々、細かく前の日に出た不具合（主に距離とタイムの検出）を直したりしてデプロイのバージョンは現在54。

差分がわかるよう、そろそろGitHubでバージョン管理したくなってきた。

https://tonari-it.com/gas-github-assistant-introduction/

## sendではなくreplyを使う
画像から文字を取り出すのに使っているVision APIの方が気になっていたのですが、先にLINEのAPIも上限があることを知り、一瞬真っ青に。

プッシュで送信するsendは課金対象、ReplyTokenを使って送信するreplyは対象外とのこと、ボットはそうじゃなきゃやってられないよねー　なるほど。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3381/eab9a37c-de8b-e118-6544-1ccdcea7d359.png)
そんなわけで、すべてreplyに変更しました。
## インタラクションは丁寧に
修正機能をそっとリリースしたとき、いつも気づいて試してくれるメンバーさんが、タイムに1時間23分45秒と入力してきてくれた。

fillInTextには nullと出てしまっていた。

検出できなかった時の事前入力は 0:00:00と入れておくようにした。

## Webhookには複数のeventがホントにきた
最初はそこらじゅうで、events[0].source とか書いちゃうんですが、ちゃんと対応しておかないと、[0]にテキスト、[1]にイメージとかでほんとに一つのWebhookで複数のイベントが来ます。ときどき。
ラン画像の認識が調子良くなってきたと思い始めた頃に、何でもないいつも成功する画像が読み取られず。

はい、[0]のままでした。。

# TODO
・ユーザ名の修正
・ともだち登録のトークでの動作調整
・番外編の記事書く（プロジェクト等諸設定、開発の便利機能などのまとめ）

現状運用している中では、あとは名前の設定だけできればメンテなしで使えるところまできた！

スプレッドシートに「Product Backlog」タブを作って気づいたことを書き足しては優先順位の並べ替えをしている。これもGitHubリポジトリが用意できたらissueで公開できるといいんだろうな。

# 参考記事・サイト

https://developers.line.biz/ja/reference/messaging-api/#postback-action

https://developers.line.biz/ja/docs/messaging-api/using-quick-reply/