LINE BOTからGoogleカレンダーの予定の取得・追加を行う

LINEでやりとりをすることが多いのですが、会話の中で決まった予定を、LINEのbotに話しかけてGoogleカレンダーに打ち込めたら楽だろうと思って、GoogleAppsScript（以下、GAS）で作ることにしました。
**無料**でサーバレスなので、簡単なwebアプリケーションならGASも検討して良いと思います。

#完成品

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">うちの新田美波(自作のlinebot)は予定を教えてくれるし追加もできる<a href="https://twitter.com/hashtag/imas_cg?src=hash">#imas_cg</a> <a href="https://twitter.com/hashtag/linebot?src=hash">#linebot</a> <a href="https://t.co/VSQncIhVZ1">pic.twitter.com/VSQncIhVZ1</a></p>&mdash; がわわ (@gawawa124) <a href="https://twitter.com/gawawa124/status/877154790752440320">2017年6月20日</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

#コード
http://inside.pixiv.net/entry/2016/11/14/150000
https://tonari-it.com/gas-calendar/
上記の記事をとても参考にして実装しました。

```
var CHANNEL_ACCESS_TOKEN = "Your CHANNEL_ACCESS_TOKEN";
var ERROR_SHEET_ID = "Your Spreadsheet ID for Error";

var dateExp = /(\d{2})\/(\d{2})\s(\d{2}):(\d{2})/;
var dayExp = /(\d+)[\/月](\d+)/;
var hourMinExp = /(\d+)[:時](\d+)*/;

function doPost(e) {
  try {
    handleMessage(e);
  } catch(error) {
    logging("ToCalendarFromLineBot");
    logging(JSON.stringify(e));
    logging(JSON.stringify(error));
    var replyToken = JSON.parse(e.postData.contents).events[0].replyToken;
    reply(replyToken, error.message);
  }
}

function logging(str) {
  var sheet = SpreadsheetApp.openById(ERROR_SHEET_ID).getActiveSheet();
  var ts = new Date().toLocaleString("japanese", {timeZone: "Asia/Osaka"});
  sheet.appendRow([ts, str]);
}

function handleMessage(e) {
  var replyToken = JSON.parse(e.postData.contents).events[0].replyToken;
  var lineType = JSON.parse(e.postData.contents).events[0].type
  if (typeof replyToken === "undefined" || lineType === "follow") {
    return;
  }
  var userMessage = JSON.parse(e.postData.contents).events[0].message.text;
  var cache = CacheService.getScriptCache();
  var type = cache.get("type");
  
  if (type === null) {
    if (userMessage === "予定") {
      cache.put("type", 1);
      reply(replyToken, "予定日を教えてください！\n「06/17, 6月17日」などの形式なら大丈夫です！");
    } else if (userMessage === "予定教えて") {
      reply(replyToken, getEvents());
    } else {
      reply(replyToken, "「予定」で予定追加を、「予定教えて」で予定参照ができるので気軽に話しかけてくださいね！");
    }
  } else {
    if (userMessage === "キャンセル") {
      cache.remove("type");
      reply(replyToken, "キャンセルしました！");
      return;
    }
    
    switch(type) {
      case "1":
        // 予定日
        var [matched, month, day] = userMessage.match(dayExp);
        cache.put("type", 2);
        cache.put("month", month);
        cache.put("day", day);
        reply(replyToken, month + "/" + day + "ですね！　次に開始時刻を教えてください。「13:00, 13時, 13:20, 13時20分」などの形式なら大丈夫です！");
        break;
      case "2":
        // 開始時刻
        var [matched, startHour, startMin] = userMessage.match(hourMinExp);
        cache.put("type", 3);
        cache.put("start_hour", startHour);
        if (startMin == null) startMin = "00";
        cache.put("start_min", startMin);
        reply(replyToken, startHour + ":" + startMin + "ですね！　次に終了時刻を教えてください。");
        break;
      case "3":
        // 終了時刻
        var [matched, endHour, endMin] = userMessage.match(hourMinExp);
        cache.put("type", 4);
        cache.put("end_hour", endHour);
        if (endMin == null) endMin = "00";
        cache.put("end_min", endMin);
        reply(replyToken, endHour + ":" + endMin + "ですね！　最後に予定名を教えてください！");
        break;
      case "4":
        // 予定名
        cache.put("type", 5);
        cache.put("title", userMessage);
        var [title, startDate, endDate] = createEventData(cache);
        reply(replyToken, toEventFormat(title, startDate, endDate) + "\nで間違いないでしょうか？ よろしければ「はい」をやり直す場合は「いいえ」をお願いいたします！");
        break;
      case "5":
        // 確認の回答がはい or いいえ
        cache.remove("type");
        if (userMessage === "はい") {
          var [title, startDate, endDate] = createEventData(cache);
          CalendarApp.getDefaultCalendar().createEvent(title, startDate, endDate);
          reply(replyToken, "追加しました！\nお疲れ様でした！");
        } else {
          reply(replyToken, "お手数ですがもう一度お願いいたします！");
        }
        break;
      default:
        reply(replyToken, "申し訳ありません。\n形式に誤りがないか確認してみて、なければ「キャンセル」で予定入力をキャンセルすることができるので、そちらを試していただけますか？");
        break;
    }
  }
}

function createEventData(cache) {
  var year = new Date().getFullYear();
  var title = cache.get("title");
  var startDate = new Date(year, cache.get("month") - 1, cache.get("day"), cache.get("start_hour"), cache.get("start_min"));
  var endDate = new Date(year, cache.get("month") - 1, cache.get("day"), cache.get("end_hour"), cache.get("end_min"));
  return [title, startDate, endDate];
}

function toEventFormat(title, startDate, endDate) {
  var start = Utilities.formatDate(startDate, "JST", "MM/dd HH:mm");
  var end = Utilities.formatDate(endDate, "JST", "MM/dd HH:mm");
  var str = title + ": " + start + " ~ " + end;
  return str;
}

function reply(replyToken, message) {
  var url = "https://api.line.me/v2/bot/message/reply";
  UrlFetchApp.fetch(url, {
    "headers": {
      "Content-Type": "application/json; charset=UTF-8",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN,
    },
    "method": "post",
    "payload": JSON.stringify({
      "replyToken": replyToken,
      "messages": [{
        "type": "text",
        "text": message,
      }],
    }),
  });
  return ContentService.createTextOutput(JSON.stringify({"content": "post ok"})).setMimeType(ContentService.MimeType.JSON);
}

function getEvents() {
  var events = CalendarApp.getDefaultCalendar().getEventsForDay(new Date());
  var body = "今日の予定は";
  
  if (events.length === 0) {
    body += "ありません！";
    return body;
  }
  
  body += "\n";
  events.forEach(function(event) {
    var title = event.getTitle();
    var start = toHHmm(event.getStartTime());
    var end = toHHmm(event.getEndTime());
    body += "＊" + title + ": " + start + " ~ " + end + "\n";
  });
  body += "です！ 張り切っていきましょ！";
  return body;
}

function toHHmm(date){
  return Utilities.formatDate(date, "JST", "HH:mm");
}
```

#つまずいたところなどの解説
### webアプリケーションを公開する際の注意点
「ウェブアプリケーションとして導入」する際に「プロジェクトバージョン」を**毎回新規作成**してください。
こうしないと更新されません。
GASではプロジェクトバージョンを毎回新規作成することでバージョンごとのスナップショットが作られているようです。

###function logging
LINE botとやりとりしていてエラーが発生した場合、botからの応答がなくなります。
Logger.logでログが吐かれないので、エラーが発生した際にスプレッドシートに書くことでデバッグを行いやすくしています。
http://yoshiyuki-hirano.hatenablog.jp/entry/2015/10/09/144237

###Cache
ユーザーが予定を入力する時に、予定名や日時を一気に打つのは使いづらいbotと言えます。
なので、日時や予定名で区切ってやりとりするわけですが、その際にデータを一時保存したいわけです。
データベースを使うかスプレッドシートを使うか色々考えた上でGASで用意されている**Cache**を使うことにしました。
key-valueのvalueは全てStringになるところが注意点です。
https://developers.google.com/apps-script/reference/cache

###改良点
- 今のままだと来年の予定が入力できない
- 終了時刻は「〜時間後」の形式も受け付けた方が親切かも

余力があれば上記も実装したかったです。

###このbotを外部に公開できるのか？
結論から言うと僕にはできませんでした。わかる方がいたら教えていただきたいです。
理由は、LINE botからGASにアクセスする際に、**アクセスしたユーザーのGoogleカレンダーの利用権限についてbotが覚えておけない**ためです。

今は僕をユーザーとしてwebアプリケーションを動かしているので、すでに僕のGoogleカレンダーの利用権限は認証された状態です。しかし、このまま外部に公開してしまえば、いろんなユーザーが**僕のカレンダー**に予定を追加したり参照してしまいます。

なので、LINE botを使うユーザーごとに動かす必要があって、ユーザーごとにGoogleカレンダーの利用権限が必要になります。
しかし、botからGASにアクセスする際に、ユーザーが利用権限を許可したかどうかがわからないためGoogleカレンダーの取得・追加ができないという結論に至りました。

ということで、この記事のbotが欲しい！　という場合はGASやLINE botの勉強も兼ねて実装してみてください。
今は僕がこのbotを独り占めしておきます。

#参考記事
http://inside.pixiv.net/entry/2016/11/14/150000
https://tonari-it.com/gas-calendar/
http://yoshiyuki-hirano.hatenablog.jp/entry/2015/10/09/144237
https://developers.google.com/apps-script/reference/cache