LINE messaging APIを使ってGoogleカレンダーと連携した予約ツールを作った（β版）

以前書いていた記事について、関連した内容でいろいろ進捗があったので備忘録を兼ねてOutputです。

https://qiita.com/kuma_radcof/items/951395246c46c57bd2df

## 求める機能
- LINE上でのやりとりでサービスの予約ができる
- 予約が確定したら管理目的にスプレッドシートとGoogleカレンダーへ情報を転記する


## 期待できる効果
- ユーザーは公式LINEに登録することでメッセージ上のやり取りだけで予約ができるので、時間を（ある程度）気にせず予約を取ることができる
- 事業者は電話のやり取りやSNSでの返信などの手間がなくなるので予約にかかる工数の削減が見込める

予約できる時間をある程度、としたのは、24時間すべてOKにするとデータの更新とかを考えた時にラグが出てしまってダブルブッキングとか起こり得るなと思ったので、たとえば9時〜23時までを予約可能時間として0時台に予約リストを更新するという手続きが取れるようになるので、『ある程度』としました。
これでも通常なら営業時間内に電話しないと予約って取れないので、良いのかなと。

このツールの良い点は**応答メッセージ**でのやり取りで予約を完結させる点です。LINEは複数のメッセージ送信方法がありますが、応答メッセージを使えばメッセージ通数を気にせず送信することができます。なのでLINE公式アカウントに課金しなくてもこのツールが使えるというのがメリットです。

他にも特別な管理画面とかツールの学習しなくてもエクセルに文字を入力することのできるITリテラシーがあれば使えるというのもメリットになるかと思います。
いいツールがあっても、画面構造を理解して使いこなせるようになるまでって結構大変じゃないですか、あれを極力無くしたかったのでスプレッドシートメインで作成しました。

## ツール利用の流れ
1. スプレッドシート側の基本設定
    まずは事業者側が自身の店名や営業時間、休憩時間といった情報を入力します。
    入力完了というボタンをクリックすることで、スクリプトに必要情報が入力されたりシートが自動で作成されるようにします（ここはアップデートでの対応）
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/524273/3db89287-2437-8db1-ce5d-d1f665db1648.png)

1. 公式LINEアカウントの作成とMessaging apiの有効化
    ここは事業者でやってもらってもいいですが、割と面倒なので自分がサポートする前提で作成しています

1. リッチメニューの用意
    メッセージやり取りでも問題ないですが、リッチメニューの方がエラーリスク少ないように思います（ここもアップデートでの対応）


## スクリプトの流れ
今回のツールはTypescriptベースで作成し、ClaspでGASにpushして利用しています。
- LINEへ応答メッセージを送る関数
- 確定した予約情報をスプレッドシートへ情報を転記する関数
- 確定した予約情報を整理してGoogleカレンダーへ予定登録する関数
- 上記をまとめてpostリクエストを処理するdoPost関数

をおおまかには用意しました（他にもスプレッドシートの情報を整理する関数とか作ったけど、本題から逸れるので今回は割愛）

### LINEへ応答メッセージを送る関数：replyToLine関数
```typescript:sendLineMessage.ts
function replyToLine(replyToken: string, messageBody: {}[]) {
    const headers = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + LINE_ACCESS_TOKEN
    };

    const requestBody = {
        replyToken: replyToken,
        messages: messageBody
    };

    const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions= {
        method: 'post',
        headers: headers,
        payload: JSON.stringify(requestBody)
    };

    UrlFetchApp.fetch(LINE_ENDPOINT, options);
}
```
この関数に対して、doPost関数の中で得られたreplyTokenを引数に渡してメッセージ本文を用意することで応答メッセージを送れるようにしました。

### 確定した予約情報をスプレッドシートへ転記する関数：setReservationData関数
```typescript:sendLineMessage.ts
function setReservationData(data: string[]){
    const spreadsheetId = PropertiesService.getScriptProperties().getProperty("SPREADSHEET_ID");
    if(!spreadsheetId) return;
    const ss = SpreadsheetApp.openById(spreadsheetId);
    const listSheet = ss.getSheetByName(listSheetName);

    if(!listSheet) return ;
    const lastRow: number = listSheet?.getLastRow() || listSheet?.getRange(1,1).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow();

    listSheet?.getRange(lastRow + 1, 1, 1, 5).setValues([data]);
}

```
doPost関数の中でスプレッドシートを呼び出すので、`ActiveSpreadsheet`ではなく`openById`を利用してコードを作成しました。

### 確定した予約情報を整理してGoogleカレンダーへ予定登録する関数：setReservationDataforCalendar関数
```typescript:sendLineMessage.ts
function setReservationDataforCalendar(data: string[]){
    // data = ["1月28日", "11:00", "3", "佐藤", "01234568899"]
    // 対象のカレンダー
    const calendarId = PropertiesService.getScriptProperties().getProperty("MYCALENDAR_ID");
    // console.log(calendarId);
    if(!calendarId) return;
    const myCalendar = CalendarApp.getCalendarById(calendarId);

    // スプレッドシートから予約時間の一枠時間を取得
    const spreadsheetId = PropertiesService.getScriptProperties().getProperty("SPREADSHEET_ID");
    if(!spreadsheetId) return;
    const ss = SpreadsheetApp.openById(spreadsheetId);
    const baseSheet = ss.getSheetByName(baseSheetName);
    const step: number = baseSheet?.getRange(15, 3).getValue();

    // 引数から予約情報を用意する
    const [date, time, count, name, tel] = data;
    const title = `${name}様／${count}名／${tel}`;
    const formatDate = convertJapaneseDateToDate(date);
    const hour = parseInt(time.slice(0,2));
    const minute = parseInt(time.slice(3, 5));

    if(!formatDate || formatDate === null) return;
    const eventStartDate = new Date(formatDate[0], formatDate[1], formatDate[2], hour, minute, 0);
    const eventEndDate = new Date(formatDate[0], formatDate[1], formatDate[2], hour, minute + step, 0);

    myCalendar.createEvent(title, eventStartDate, eventEndDate);
}
```

### postリクエストを処理するdoPost関数（抜粋）
```typescript:sendLineMessage.ts
function doPost(e) {
    try {
        const webhookEvents = JSON.parse(e.postData.contents);
        if (!webhookEvents.events || webhookEvents.events.length === 0) {
            return ContentService.createTextOutput(JSON.stringify({ status: "No events" })).setMimeType(ContentService.MimeType.JSON);
        }
        const replyToken = webhookEvents.events[0].replyToken;
        const receivedMessage = webhookEvents.events[0].message.text;
        const userId = webhookEvents.events[0].source.userId;  // ユーザーIDの取得

        const userCache = CacheService.getUserCache();
        let cacheData = userCache.get(userId);
        let objectData; 

        if(!cacheData){
            const cacheDataDetail = {
                userID: userId,
                reservationStep:"",
                date:"",
                time:"",
                count:"",
                name:"",
                tel:"",
            }
            objectData = cacheDataDetail;
        } else {
            objectData = JSON.parse(cacheData);
        }

        // 日付選択に必要
        const dateInfoObject = getDateobject();


        // 初回メッセージ処理: "予約"を受け取った場合
        if (receivedMessage === "予約") {
            const dateInfoString = dateInfoObject.map((item) => `${item.num}：${item.date}`).join("\n");
            const messageBody = [
                {
                    "type": "text",
                    "text": `ご利用ありがとうございます。\n予約botが対応いたします。\n\n日付・時間・人数・代表者お名前・電話番号を教えていただきます。`
                },
                {
                    "type": "text",
                    "text": `【質問】\nまずはじめに次の日付から希望日を1~8の数字で教えてください。\n\n${dateInfoString}`
                },
            ];
            replyToLine(replyToken, messageBody);
            
            // キャッシュの更新処理
            objectData.reservationStep = "waitingDate";
            userCache.put(userId, JSON.stringify(objectData), 3600)
            return ContentService.createTextOutput(JSON.stringify({ status: "200" })).setMimeType(ContentService.MimeType.JSON);
        } 

//〜中略〜

    } catch (error) {
        Logger.log("Error: " + error.toString());
        return ContentService.createTextOutput(JSON.stringify({ status: "500", error: error.toString() }))
            .setMimeType(ContentService.MimeType.JSON);
    }
}
```

## 制作する上ではまったこと・うまくいかなかったこと
今回は応答メッセージのやり取りでどんどん情報を取得していきたかったのですが、これをどうしていったら良いのか、日付を選択しても、次に時間を選択したら前の情報が消えてしまうのではという考えがあって悩みました。
メンター（ChatGPT）に聞いてみたところ、GAS組み込みのCacheサービスを利用すると解決できるということなので、キャッシュの利用にチャレンジしました。

キャッシュサービスにもいろいろあるようでしたが、今回はuserCacheというものを使い、LINEのユーザーIDを取得してキーとすることでユーザーごとに情報を管理させて応答メッセージのたびにデータを蓄積・更新させていくことにしました。
キャッシュの中身は自分で決めることができ、オブジェクトとして扱う（キーと値がある）ことができるので、ユーザーIDごとに予約ステップを都度更新し、そこに合わせてユーザーが回答したデータを日付や時間に格納していくという形で対応していきました。

## デモ動画（GIF）
実際にLINEでやり取りを行なって、左側に見えるスプレッドシートへ情報が転記され、その後Googleカレンダーに予定が登録されるところまでを記録しています。GIFにしたら画質が荒くなったので、雰囲気だけ。

![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/524273/17b3b3fa-c2e6-b37d-2c9a-62f9295440e9.gif)



## おまけ：今回の知識・スキルの応用
今回の応答メッセージとキャッシュ機能を使っていけば、ユーザーとのやり取りで情報を蓄積させていくことができるので、予定と持ち物を登録しておいて、予定の前日になったらリマインドメッセージ送るようなツールとか作ることができるなぁと思ってます（このリマインド通知はpushメッセージとかになるので通数を消費することになる）
おしまい