LINE Bot + SpreadSheet + Google Calendar で予約アプリを作る - 3

# この記事について
LINE BotとGoogleのサービスだけで予約システムを構築することが目的です．
この記事は続きなのでまだの人は[前前回の記事](https://qiita.com/yodai-hi/items/1b18a517466708bfe3cc)，[前回の記事](https://qiita.com/yodai-hi/items/370402955a70f318ff2c)を見てからのほうがわかりやすいと思います．

## Google Apps Scriptの作成
前前回に作ったSpreadSheetの[ツール]>[スクリプトエディタ]からGASを作成します．
GASが行う作業は主に以下の２つです．今回はわかりやすくするためにコードを分割しています．

1. LINEのユーザ情報，LINE BotからのBooking Dataデータをスプレッドシートに保存する．
2. スプレッドシートのBooking Dataに更新があったとき，カレンダーに書き込むor予約不可を返す．

![programs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317621/874cac6a-7dc4-c9bd-f0cf-1c7472e95241.png)

### LineBot
基本的に入力はすべて**LineBot**が担います．

```JavaScript:LineBot.gs
///<reference path="SheetUsersHandler.ts"/>
///<reference path="CalendarHandler.ts"/>
///<reference path="LineMessages.ts"/>

/*外部との通信を行う*/

//GETのハンドリング
function doGet(e) {
    return ContentService.createTextOutput("Hello World\n\n"+e.toString());
}

//POSTのハンドリング
function doPost(e) {
    const json = JSON.parse(e.postData.contents);
    //トリガーの共通部分
    const event = json.events[0];
    const eventType = event.type;
    // user情報
    const id = event.source.userId;


    //フォローされた
    if (eventType == "follow") {
        let user = getUserInfo(id);
        let name = user.displayName;
        let icon = user.pictureUrl;
        let statusMessage = user.statusMessage;
        let createdAt = new Date(event.timestamp);
        addUser([id, name, icon, statusMessage, createdAt, false]);
    }

    //ブロックされた
    else if (eventType == "unfollow") {
        deleteUser(id);
    }

    //postbackイベント
    else if (eventType == "postback") {
        const postback = JSON.parse(event.postback.data);

        /* =============予約============= */
        if(postback.action == "booking"){
            //予約開始
            if (postback.status == "start") {
                bookingNow(id); //予約中のフラグを立てる
                bookingDate(id); //日付選択へ
            }
            //日付選択完了
            if (postback.status == "date") {
                const date = new Date(event.postback.params.datetime);
                let name = getNameById(id);
                addNewBooking(id, name, date) //予約情報をBookingに追加
                bookingCourse(id); //コース選択へ
            }
            //コース選択完了
            if (postback.status == "course") {
                const course = postback.value;
                const rowNum = getLatestBookingById(id, 4, course);
                sendToCalendar(rowNum); //予約データの保存処理
            }
        }
    }

    //イベントを終了
    else if (eventType == "message") {
        const text = event.message.text;
        if (text == "中断") {
            //今予約中なら
            if (checkBookingNow(id)) {
                deleteLatestBookingById(id); //データの破棄
            }
        }
    }
}
```

予約をするには，開始日時と終了日時の入力が必要です．今回のシステムでは，終了日時は入力せず，15min or 30min or 60min or 90min のコースから一つ選ぶこととします．この時にpostbackのデータで現在もアクションとステータスをGASに送信します．
GASはonPOSTで入ってくるデータに対してそれぞれ分岐された処理を行います．
![booking.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317621/846c8bf9-2dd3-8981-0906-9a15b517e26a.png)

### CalendarHandler
カレンダーへの予定の入力を担います．

```JavaScript:CalendarHandler.gs
///<reference path="LineBot.ts"/>

/*カレンダーとの連携を行う*/


//カレンダーの情報を取得
function initCalendar() {
    //Configから環境情報の読み込み
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Config");
    const parallel = sheet.getRange("B5").getValue();
    const calendarId = sheet.getRange("B6").getValue();
    //カレンダーインスタンスの生成
    const calendar = CalendarApp.getCalendarById(calendarId);
    return {
        parallel: parallel,
        calendar: calendar,
    }
}
//カレンダーに予約を登録する関数
function sendToCalendar(row) {
    const info = initCalendar()
    let message;
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Booking");
    //登録する情報
    const id = sheet.getRange(row, 1).getValue();
    const name = sheet.getRange(row, 2).getValue();
    //登録に必要な時間情報
    const s_time = new Date(sheet.getRange(row, 3).getValue());
    const delta = sheet.getRange(row, 4).getValue();
    //予約の終了時間を計算
    const e_time = new Date(sheet.getRange(row, 3).getValue());
    const min = (e_time.getMinutes() + delta) % 60;
    const hour = e_time.getHours() + (e_time.getMinutes() + delta - ((e_time.getMinutes() + delta) % 60)) / 60;
    e_time.setHours(hour);
    e_time.setMinutes(min);

    try {
        //この時間帯が空いているかどうか
        if (checkBookable(info.calendar, s_time, e_time, info.parallel)) {
            //予約情報をカレンダーに追加
            const thing = name + "様　ご予約";
            info.calendar.createEvent(thing, s_time, e_time);
            message = name + "様　\n\n" + datetimeJapanFormatter(s_time) + "〜" + datetimeJapanFormatter(e_time) + "\n\n でご予約を承りました。\n\n ありがとうございました。";
            getLatestBookingById(id, 5, true);
        }
        else {
            message = name + "様　\n\n ご予約の時間に先約がありましたので、\n 申し訳ございませんが、ご予約いただけませんでした。\n\n ご予定を変更して再度お申込みください。";
            getLatestBookingById(id, 5, false);
        }
    }
    catch (exp) {
        //実行に失敗した時に通知
        message = "予約に失敗しました。 \n 時間を置いてもう一度やり直してください。";
        getLatestBookingById(id, 5, false);

    }
    //Botにメッセージを送信
    sendMessage(id, message);
}

// 先約があるかどうか調べる関数
function checkBookable(calendar, s_time, e_time, parallel) {
    let end_min;
    let end_hour;
    let start_min;
    let start_hour;

    const events = calendar.getEvents(s_time, e_time);
    const time_array = [];
    for (let ev in events) {
        if (events.hasOwnProperty(ev)){
            time_array.push(events[ev].getStartTime());
            time_array.push(events[ev].getEndTime());
        }
    }
    time_array.sort();
    let duplicate = 0;

    //予定の期間内に存在するイベントの開始，終了時刻付近の重複を調べる
    for (let i in time_array) {
        if (time_array[i] >= s_time && time_array[i] <= e_time) {
            if (time_array[i].getMinutes() == 0) {
                start_hour = time_array[i].getHours() - 1;
                start_min = 59;
            }
            else {
                start_hour = time_array[i].getHours();
                start_min = time_array[i].getMinutes() - 1;
            }
            if (time_array[i].getMinutes() == 59) {
                end_hour = time_array[i].getHours() + 1;
                end_min = 0;
            }
            else {
                end_hour = time_array[i].getHours();
                end_min = time_array[i].getMinutes() + 1;
            }
            const start = new Date(s_time);
            start.setHours(start_hour);
            start.setMinutes(start_min);
            const end = new Date(s_time);
            end.setHours(end_hour);
            end.setMinutes(end_min);
            console.log(start, end);
            const books = calendar.getEvents(start, end).length;
            if (duplicate < books) {
                duplicate = books;
            }
        }
    }
    return duplicate < parallel;
}
```
**CalendarHandler**では，SpreadSheetのConfigに入力されているカレンダーIDを使って，カレンダーに予定を新規で入力します．
ここでの処理のポイントは```function checkBookable```です．この関数はユーザが指定した時間帯に，最大で何個の予定が重なっているか？を計算します．同じ時間帯に入れることのできる予定はSpreadSheet/Configの**並行人数**で指定します．
![suplicate_booking.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/317621/134b9679-5ca8-4723-e29a-d817d40f2e7b.png)
どうやって重なっている個数を調べているかというと，予定を入れたい範囲内に，すでに存在する予定の開始時間と終了時間を抽出します．それぞれの時間の1 min前~1 min後に入っている予定の個数を調べることで，最大で何個の予定が重なっているかを計算しています．
この値と**並行人数**を比較することで予定を入れて良いか否かを判定しています．
ユーザには予定を入れれたか否かをMessageで送信します．

### LineMessages
ユーザに対してメッセージの送信を行います．
特殊なメッセージは[前回](https://qiita.com/yodai-hi/items/370402955a70f318ff2c)紹介した**LINE Bot Designer**を用いて作成しています．

```javascript:LineMessages.gs
///<reference path="Utils.ts"/>

/*ユーザへのメッセージの送信を行う*/


//環境情報の設定
function initLine() {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Config");
    const token = sheet.getRange("B7").getValue();
    const url_reply = "https://api.line.me/v2/bot/message/reply";
    const url_message = "https://api.line.me/v2/bot/message/push";
    const url_profile = "https://api.line.me/v2/bot/profile";

    const json_header = {
        "Content-Type": "application/json; charset=UTF-8",
        "Authorization": "Bearer " + token
    };

    return {
        token: token,
        url: {
            reply: url_reply,
            message: url_message,
            profile: url_profile
        },
        json_header: json_header
    };
}

//ユーザ情報の取得
function getUserInfo(id) {
    const info = initLine();
    const options = {
        "method": 'get',
        "headers": info.json_header
    };
    // @ts-ignore
    const response = UrlFetchApp.fetch(info.url.profile + "/" + id, options);
    return JSON.parse(response);
}

//日付選択メッセージを送信
function bookingDate(id) {
    const info = initLine();
    const min = datetimePickerFormatter(new Date());
    const date = new Date()
    date.setFullYear(date.getFullYear()+1)
    const max = datetimePickerFormatter(date)

    const payload = {
        "to": id,
        "messages":[
            {
                "type": "template",
                "altText": "this is a buttons template",
                "template": {
                    "type": "buttons",
                    "actions": [
                        {
                            "type": "datetimepicker",
                            "label": "日時選択",
                            "data": "{\"action\":\"booking\", \"status\":\"date\"}",
                            "mode": "datetime",
                            "initial": min,
                            "max": max,
                            "min": min
                        }
                    ],
                    "title": "予約日程",
                    "text": "予約する日を選んでください．"
                }
            }
        ]
    };

    const options = {
        "method": 'post',
        "headers": info.json_header,
        "payload": JSON.stringify(payload)
    };
    // @ts-ignore
    return UrlFetchApp.fetch(info.url.message, options);
}

//コース選択メッセージを送信
function bookingCourse(id) {
    const info = initLine();
    const payload = {
        "to": id,
        "messages":[
            {
                "type": "template",
                "altText": "this is a buttons template",
                "template": {
                    "type": "buttons",
                    "actions": [
                        {
                            "type": "postback",
                            "label": "15分コース",
                            "text": "15分コースで予約",
                            "data": "{\"action\":\"booking\", \"status\":\"course\", \"value\":15}"
                        },
                        {
                            "type": "postback",
                            "label": "30分コース",
                            "text": "30分コースで予約",
                            "data": "{\"action\":\"booking\", \"status\":\"course\", \"value\":30}"
                        },
                        {
                            "type": "postback",
                            "label": "60分コース",
                            "text": "60分コースで予約",
                            "data": "{\"action\":\"booking\", \"status\":\"course\", \"value\":60}"
                        },
                        {
                            "type": "postback",
                            "label": "90分コース",
                            "text": "90分コースで予約",
                            "data": "{\"action\":\"booking\", \"status\":\"course\", \"value\":90}"
                        }
                    ],
                    "title": "予約コース選択",
                    "text": "予約するコースを選んでください"
                }
            }
        ]
    };
    const options = {
        "method": 'post',
        "headers": info.json_header,
        "payload": JSON.stringify(payload)
    };
    console.log(options)
    // @ts-ignore
    return UrlFetchApp.fetch(info.url.message, options);
}

//メッセージを送信
function sendMessage(id, message) {
    const info = initLine();
    const payload = {
        "to": id,
        "messages": [{
            "type": "text",
            "text": message
        }]
    };
    const options = {
        "method": "post",
        "headers": info.json_header,
        "payload": JSON.stringify(payload)
    };
    // @ts-ignore
    return UrlFetchApp.fetch(info.url.message, options);
}
```

ここで用意したMessageは日時を選択できる```bookingDate```，コースを選択できる```bookingCourse```，文字列を送信する```sendMessage```の3つです．メッセージの送信は（ユーザへの返信でないときは）UserIdが必要となります．UserIdはSpreadSheet/Userで管理しています．


### SheetBookingHandler

SpreadSheet/Bookingからデータを読み/書きします．

```javascript:SheetBookingHandler.gs
///<reference path="Utils.ts"/>

/*SpreadSheetの［Booking］の編集を行う*/


//シートを取得
function initBooking() {
    return SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Booking");
}

//idで最新の予約情報を検索してデータを追加
function addNewBooking(id, name, date) {
    const sheet = initBooking();
    sheet.appendRow([id, name, date]);
}

//idで最新の予約情報を検索してデータを追加
function getLatestBookingById(id, colNum, data) {
    const sheet = initBooking();
    const rowNum =  findLastRow(sheet,id,"A:A")
    sheet.getRange(rowNum, colNum).setValue(data);
    return rowNum
}

//idで最新の予約情報を検索してその行を消去
function deleteLatestBookingById(id) {
    const sheet = initBooking();
    const rowNum = findLastRow(sheet,id,"A:A")
    sheet.deleteRows(rowNum, 1);
}
```

ここはあまり説明することがないです．
シートからのidの検索などは汎用性が高いので**Util**にまとめています．

### SheetUsersHandler
SpreadSheet/Usersからデータを読み/書きします．

```javascript:SheetUsersHandler.gs
///<reference path="Utils.ts"/>

/*SpreadSheetの［Users］の編集を行う*/


//シートを取得
function initUser() {
    return SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Users");
}
//ユーザの追加
function addUser(row) {
    //UsersからUserの読み込み
    const sheet = initUser();
    sheet.appendRow(row);
}
//ユーザの削除
function deleteUser(id) {
    //UsersからUserの読み込み
    const sheet = initUser();
    const rowNum = findRow(sheet,id,1)
    sheet.deleteRows(rowNum, 1);
}
//idからユーザ名を検索
function getNameById(id) {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Users");
    const rowNum = findRow(sheet,id,1)
    return sheet.getRange(rowNum, 2).getValue();
}
//今予約中か
function checkBookingNow(id) {
    //UsersからUserの読み込み
    const sheet = initUser();
    const rowNum = findRow(sheet,id,1)
    return sheet.getRange(rowNum, 6).getValue();
}
//予約中（アクティブ）フラグを立てる
function bookingNow(id) {
    //UsersからUserの読み込み
    const sheet = initUser();
    const rowNum = findRow(sheet,id,1)
    sheet.getRange(rowNum, 6).setValue(true);
}
```

### Utils
その他の環境に依存しない関数をまとめています．

```javascript:Utils.gs
/*環境に依存しない関数*/


//Date TImeのフォーマットに変換
/*
引数: Date
返値: String(YYYY-mm-dd t HH:mm)
*/
function datetimePickerFormatter(date){
    return `
        ${date.getFullYear()}-\
        ${(date.getMonth() + 1).toString().padStart(2, '0')}-\
        ${date.getDate().toString().padStart(2, '0')}t\
        ${date.getHours().toString().padStart(2, '0')}:\
        ${date.getMinutes().toString().padStart(2, '0')}\
        `.replace(/[\n\r ]/g, '')
}

//Date TImeのフォーマットに変換
/*
引数: Date
返値: String(YYYY-mm-dd t HH:mm)
*/
function datetimeJapanFormatter(date){
    return `
        ${(date.getMonth() + 1).toString()}月\
        ${date.getDate().toString()}日\
        ${date.getHours().toString().padStart(2, '0')}:\
        ${date.getMinutes().toString().padStart(2, '0')}\
        `.replace(/[\n\r ]/g, '')
}

//指定列の中にある要素を調べて一番最後の場所を返却
/*
引数: Sheet, any, String(A1)
返値: int
*/
function findLastRow(sheet,val,col){
    const values = sheet.getRange(col).getValues(); //A列の値を全て取得
    return values.filter(String).length;//空白の要素を除いた長さを取得
}

//指定列の中にある要素を調べて場所を返却
/*
引数: Sheet, any, int
返値: int
*/
function findRow(sheet,val,col){
    const dat = sheet.getDataRange().getValues(); //受け取ったシートのデータを二次元配列に取得

    for(let i=1; i<dat.length; i++){
        if(dat[i][col-1] === val){
            return i+1;
        }
    }
    return 0;
}
```

## 最後に
結構長くなってしまいましたが，なんとかまとめ切れました．
GASは今回はじめて触ったのですが，非常にdebugがやりにくくて苦労しました．
おかげで1日で終わらす予定が3日かかってしまいました．．．

ともあれLine, SpreadSheet, Calendarなどの普段慣れ親しんだものでシステムを作るのは初めての経験だったので，なんとか動くものが作れてホッとしています．

問題点としてはGASが重いこと．
一般公開した時にどれくらいの負荷まで動くのかは調べきれてないので，そこが心配です．
様子を見てみて，node.js + APIに切り替えるか考えます．
今回はHackathonのノリで作ったものなので，「そこどうなの？」みたいなところがあると思います．
似たよなサービス作ってる方がいたら，是非アドバイスください．
また，「こうしたほうが良いよ〜」なども気軽にコメントください．

今回のRepository → [line_bot](https://github.com/yodai-hi/line_bot)
Twitter → [@conseng2](https://twitter.com/conseng2)
GitHub → [yodai-hi](https://github.com/yodai-hi)

それではまたノシ