V8ランタイムに対応したGoogle Apps ScriptでGoogleカレンダーの予定をLINEに通知するスクリプトを書いた

Google Apps Scriptがいつの間にかV8ランタイムに対応し、新しいECMAScriptの構文が使えるようになっていました。

developers.google.com


ちょうどGoogleカレンダーの予定をLINEに通知するスクリプトを書こうと思ったタイミングでこのことに気づいたので、新しく使えるようになったECMAScriptの構文を使いながらスクリプトを書いてみました。


スクリプト作成の経緯と仕様

我が家ではGoogleカレンダーで妻と予定を共有しているのですが、運用する中で「1日の予定を能動的に確認するのが面倒だ」という話がありました。
ただ、Googleカレンダーは予定毎の通知機能はあるものの、1日の予定をまとめて通知する機能はありません。
(「今日の予定リスト」という機能でメールを送ることはできるようでしたが…)

そこで、日常的に使っているLINEに1日の予定をまとめて通知することにしました。

通知に関する仕様は以下の通りです。

  • 通知する時間と内容
    • 7:30になったら、Googleカレンダーに登録されている今日の予定をLINEの予定共有用グループに通知する
    • 21:00になったら、Googleカレンダーに登録されている明日の予定をLINEの予定共有用グループに通知する
  • 通知する内容の詳細
    • 各予定の件名、時間、場所、詳細をまとめて記載する
    • 時間の表記に関して、
      • 終日の予定は「終日」と表示する
      • 開始時間と終了時間が同じ予定は、開始時間だけをHH:mm形式で表示する
      • その他の予定は、開始時間と終了時間をHH:mm形式で表示する
  • その他
    • 予定がないときは通知しない


今回作成したスクリプト

今回作成したスクリプトは以下の通りです。日付操作にはMoment.jsを使っています。

const properties = PropertiesService.getScriptProperties();

function sendEventsForTodayToLine() {
  const today = Moment.moment();
  sendEventsToLine_(today, "今日");
}

function sendEventsForTomorrowToLine() {
  const tomorrow = Moment.moment().add(1, "days");
  sendEventsToLine_(tomorrow, "明日");
}

function sendEventsToLine_(date, dateStr) {
  // プロジェクトのプロパティからカレンダーIDを取得する
  const calendarId = properties.getProperty("GOOGLE_CALENDAR_ID");
  const calendar = CalendarApp.getCalendarById(calendarId);
  const events = calendar.getEventsForDay(date.toDate());
  // 予定がないときは通知しない
  if (!events.length) return;
  
  const message = removeHtmlTag_(createMessage_(events, dateStr));
  sendToLine_(message);
}

function createMessage_(events, dateStr) {
  const eventDetail = events
    .map(event => createEventDetail_(event))
    .join("\n");
  
  return `
${dateStr}の予定:
  
${eventDetail}`;
}

function createEventDetail_(event) {
  return `====================
件名: ${event.getTitle()}
時間: ${createTime_(event)}
場所: ${event.getLocation()}
詳細: ${event.getDescription()}`;
}

function createTime_(event) {
  if (event.isAllDayEvent()) {
    return "終日"
  }

  const startTime = Moment.moment(event.getStartTime());
  const endTime = Moment.moment(event.getEndTime());
  
  if (startTime.isSame(endTime)) {
    return formatDate_(startTime);
  }
  
  return `${formatDate_(startTime)} - ${formatDate_(endTime)}`;
}

function formatDate_(date) {
  return date.format("HH:mm");
}

function removeHtmlTag_(text) {
  return text.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, "");
}

function sendToLine_(message) {
  // プロジェクトのプロパティからアクセストークンを取得する
  const accessToken = properties.getProperty("LINE_NOTIFY_ACCESS_TOKEN");

  const options = {
    "method" : "post",
    "payload" : `message=${message}`,
    "headers" : { "Authorization" : `Bearer ${accessToken}` }
  };
  
  UrlFetchApp.fetch("https://notify-api.line.me/api/notify", options);
}

// 今日の予定を7:30に通知するようにトリガーをセットする
function setTodayEventsTrigger() {
  const date = Moment.moment().hours(7).minutes(30).seconds(0).milliseconds(0);
  const functionName = sendEventsForTodayToLine.name;
  setTrigger_(date, functionName);
}

// 明日の予定を21:00に通知するようにトリガーをセットする
function setTomorrowEventsTrigger() {
  const date = Moment.moment().hours(21).minutes(0).seconds(0).milliseconds(0);
  const functionName = sendEventsForTomorrowToLine.name;
  setTrigger_(date, functionName);
}

function setTrigger_(date, functionName){
  deleteTrigger_(functionName);
  ScriptApp.newTrigger(functionName).timeBased().at(date.toDate()).create();
}

function deleteTrigger_(functionName) {
  const triggers = ScriptApp.getProjectTriggers();
  triggers
    .filter(trigger => trigger.getHandlerFunction() == functionName)
    .forEach(trigger => ScriptApp.deleteTrigger(trigger));
}


Google Apps Scriptの使い方については以下のサイトなどに丁寧な解説があるので、本記事では説明を割愛します。

tonari-it.com


今回使った新しいECMAScriptの構文

letconst

今までは変数の宣言にvarしか使えませんでしたが、新しくletconstが使えるようになりました。
varletconstのざっくりとした違いは以下の通りです。

同一スコープ内での
再宣言
同一スコープ内での
再代入
var
let 不可
const 不可 不可


今後は基本的にconstを使い、再代入が必要なときはletを使えばよさそうです。
今回のスクリプトではconst以外を使うことはありませんでした。

アロー関数

アロー関数は=>を使って記述する関数リテラルです。
関数リテラル自体については別の記事に以前書いたので、参考にしてみてください。

Arrayクラスに用意されている高階関数(ex. map()filter()など)と組み合わせることで、配列に対する処理を簡潔に書くことができます。

const eventDetail = events
  // 各イベントオブジェクトから詳細情報を取得する
  .map(event => getEventDetail_(event))
  // 改行コードで結合する
  .join("\n");


テンプレート文字列

`で括ることでテンプレート文字列を作成できます。
複数行の文字列や、${hoge}の形式で変数や式を記述できます。

return `====================
件名: ${event.getTitle()}
時間: ${createTime_(event)}
場所: ${event.getLocation()}
詳細: ${event.getDescription()}`;


なお、テンプレート文字列に対してtrim()を実行することで行頭のスペースを一括削除できるかと思ったのですが、想定した形で削除されなかったので、行頭のスペースは手動で削除しました。


その他の便利な構文など

関数名.nameで関数名の文字列が取得できる

トリガーを削除するとき、最初はベタ書きで関数名を指定していたのですが、これによりTYPOを回避できるようになりました。


関数名の末尾に_を付けると「関数を選択」の候補に表示されなくなる

これにより外部から実行する関数だけが「関数を選択」の候補に表示されるようになる*1ので、動作確認が楽になりました。
(ただ、トリガー設定の候補には出てきてしまうので、そこまで恩恵はないかもしれないです)


不便と感じた点

一部のオブジェクトのプロパティやメソッドの補完が効かない点(ex. map()内の配列の要素)は相変わらず使いにくさを感じました。
また、ソースコード管理の点でも煩わしさを感じましたが、こちらについては最終的にブラウザ上で実行するため後からいくらでも編集できてしまうので、仕方ないかなとも思いました。


まとめ

久々にGoogle Apps Scriptを使いましたが、V8ランタイムに対応したことでスクリプトを書くのがだいぶ楽しくなった印象です。
次はclaspを使ってローカルで開発できるようにしてみたり、TypeScriptで実装してみようかなと思っています。