14
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

社内の発表会をすっぽかしたので、MTGの30分前にAlexaが教えてくれる仕組みを整備した話

14
Posted at

1. はじめに

先日、社内の発表会をすっぽかしてしまいました。

弊社はフレックス制なので朝の出社時間が日によって異なり、いつもなら寝る前に「明日早めの予定合った気がするな」という感覚に頼ってカレンダーを確認するという風にしていました。

しかし、その日はいつもは別の曜日に予定されている発表会がリスケされていた & 前日遅くまで対応する案件があったという2件が重なったのもあり、(言い訳ですが...)すっぽかしてしまいました。

この日から前日の退勤時に翌日の最も早い予定を確認する習慣をつけましたが、それでは弱い。仕組み化まで持っていきたいなと思いました。

そこで、Claudeに頼んで1日のはじめのMTGの30分前に通知する仕組みを作ることにしました。最初はCoworkで定期実行するのかなと思っていたのですが、どうやらGoogle Apps Scriptでうまくできるようだったので、それを軸に構成してもらいました。

最終的な構成としては

  1. Googleカレンダーを毎朝見に行き、その日の最初のMTGを取得
  2. そのMTGの30分前にGoogle Apps Script(GAS)から仮想通知トリガーというものに命令を送る
  3. 仮想通知トリガーが発火して、Alexaのスキル経由で定型アクションが発火する

という形になっています。

2. なぜ「その日の最初の1件だけ」なのか

先に設計の話をしておきます。この仕組みは、その日の最初のMTG1件だけを通知します。

理由は2つあります。

1つ目は、2件目以降はもう気づけているからです。MeetingBarというアプリを使っていて、次の予定がメニューバーに常に出ています。作業中に何度も目に入る & 時間になったら全画面での通知がくるので、これで十分間に合います。

2つ目は、その1件目だけが読めないからです。改めてになりますが弊社はフレックス制なので、始業時刻が日によって変わります。「毎朝9時に通知」のような固定の仕組みでは、早く始めた日には早すぎるし、遅く始めた日には手遅れになります。

そして実際に遅刻したのも1件目でした。始業していなければPCも開いていないということなので、一番見落としやすいです。

そのため、困っているところにだけ手を打つことにしました。1件目は開始時刻から逆算して声で知らせる、2件目以降はMeetingBarに任せる、という分担です。

3. 作ったものの全体像

先ほど少し触れましたが、全体としてはこういう流れになっています。

毎朝5:00   GASがその日の最初の「Meetリンク付きの予定」を探す
             ↓
           開始30分前に発火する1回限りのトリガーを作る
             ↓
開始30分前  GASがWebhookを叩く
             ↓
           Alexaの定型アクションが発火
             ↓
           「30分前になりました」→ 音楽15分 → 25分待機 →「5分前です」

GASが叩くのは1回だけです。

30分前と5分前の2回喋らせているのですが、そのためにGAS側でトリガーを2つ作ってはいません。Alexaの定型アクションには「〇分待機する」というアクションがあるので、2回目はそちらに任せています。

4. Alexa側の定型アクション

ここは自分でAlexaアプリを触って組んだところなので、はっきり書けます。

4.1. 実行条件

「仮想トリガーボタン」というスキルを有効にしておくと、外からWebhookを叩いて定型アクションを発火させられるようになります。APIキーとトリガーIDは https://alexa.hapicom.jp/ で取得できます。

これを実行条件にして、「最初のMTGの30分前連絡」という名前で定型アクションを作りました。時間指定はなしです。

※ 無料版だとトリガーは1つしか設定できないので、複数の自動化をしたい場合は課金が必要になります

4.2. アクションの中身

上から順に、こう並べています。

順番 アクション
1 「おはようございます。本日最初のミーティングの30分前になりました。ミーティングの準備をお願いいたします。」と言う
2 通知を送信: 本日最初のMTGの30分前になりました
3 「spotifyで15分音楽を流して」
4 25分待機する
5 「ミーティング5分前です。準備は出来ていますか?」と言う

1で喋らせて、2でスマホにも通知を飛ばしています。一連の流れが本当に機能しているのかテストしていたころの名残ですが、特に消す理由もないので残しています。

3で音楽を鳴らしているのはアラーム代わりです。寝ているときに起きられる可能性をあげるためのものです。出社中に家で鳴っているのを毎回止めるのは面倒なので、15分で勝手に止まるように指示しています。

そして4と5で、5分前にもう一度声をかけてもらいます。30分前に発火してから25分待つので、ちょうど5分前になる計算です。

4.3. 「25分待機」について

この待機アクションについては、Alexaアプリ自身が画面上で説明してくれていました。

カスタムアクションの後に待機アクションを追加すると、次のアクションの開始時間を指定して次のアクションによる中断を防ぐことができます。

定型アクションは上から順に実行されるので、待機を挟まないと、次のアクションが前のアクションを蹴ってしまうということのようです。音楽を15分流している最中に5分前アナウンスが割り込んでこないのは、この25分待機のおかげということですね。

5. GAS側でやっていること(多分)

ここからはAIに書いてもらった部分なので、読んで「多分こういうことだろう」と思った範囲で書きます。

5.1. 設定

先頭に設定がまとまっています。ここは自分でいじりました。

var PLAN_HOUR   = 5;                   // 毎朝この時刻に予定を確認(JST)
var LEAD_MIN    = 30;                  // 何分前に通知するか
var CALENDAR_ID = 'you@example.com';   // 自分のアカウントに置き換えてください
var TZ          = 'Asia/Tokyo';

PLAN_HOURを5にしているのは、その日のMTGが始まる前でさえあれば何時でも良いからで、特に深い理由はありません。

CALENDAR_IDをわざわざ指定しているのには理由があるのですが、それは後の6.1で書きます。

5.2. 毎朝、その日の分のトリガーを作っている

一番の肝はここらしいです。

GASのトリガーは「毎日9時」のような定期実行はすぐ作れるのですが、MTGの開始時刻は日によって変わるので、それでは対応できません。そこで、毎朝5時にその日の予定を調べて、「今日のこの時刻に1回だけ実行する」トリガーをその場で作る、という形になっています。

function planDay() {
  clearTriggers_('fireAlexa');

  var ev = firstMeetEventToday_();
  if (!ev) {
    Logger.log('本日、Meetリンク付きの予定はありません');
    return;
  }

  var start = new Date(ev.start.dateTime);
  var fireAt = new Date(start.getTime() - LEAD_MIN * 60 * 1000);

  if (fireAt <= new Date()) {
    Logger.log('「%s」(%s) は既に%d分前を過ぎています。スキップ',
      ev.summary, start, LEAD_MIN);
    return;
  }

  ScriptApp.newTrigger('fireAlexa').timeBased().at(fireAt).create();
  Logger.log('「%s」開始 %s → %s に通知予定', ev.summary, start, fireAt);
}

最初と最後に出てくるclearTriggers_()は、使い終わったトリガーを消す処理です。1回限りのトリガーも実行後に残り続けるらしく、GASにはトリガー数の上限があるので、放っておくとそのうち作れなくなるのだと思います。

途中のfireAt <= new Date()は、既に30分前を過ぎているときにスキップする処理ですね。手で実行したときのためのものだと思います。

5.3. 予定の絞り込みが結構細かい

その日の予定を全部取ってきて、条件に合う最初のものを返している関数もあります。

除外条件が3つ入っていて、これは自分では思いつかなかったところです。

  • statuscancelledのもの(キャンセルされた予定)
  • start.dateTimeが無いもの(終日予定。誕生日とか休暇とか)
  • 自分が辞退した予定(参加者リストの中の自分のresponseStatusdeclinedのもの)

そのうえで、Meetリンクを持っているかどうかを判定しています。この判定も3段構えになっていて、hangoutLinkを見て、conferenceDataの中を見て、最後に本文と場所欄にmeet.google.comが入っていないかまで見ています。

function hasMeet_(e) {
  if (e.hangoutLink) return true;
  if (e.conferenceData && e.conferenceData.entryPoints) {
    for (var i = 0; i < e.conferenceData.entryPoints.length; i++) {
      var uri = e.conferenceData.entryPoints[i].uri || '';
      if (uri.indexOf('meet.google.com') !== -1) return true;
    }
  }
  var blob = (e.location || '') + ' ' + (e.description || '');
  return blob.indexOf('meet.google.com') !== -1;
}

言われてみれば、予定の作られ方によってMeetリンクの入る場所が違うのだろうなとは思います。とはいえ、自分で書いていたらhangoutLinkだけ見て終わっていたはずです。

5.4. Alexaを鳴らしているところ

30分前に呼ばれる関数です。Webhookを1回叩くだけなので短いです。

function fireAlexa() {
  var props = PropertiesService.getScriptProperties();
  var key = props.getProperty('HAPICOM_API_KEY');
  var trg = props.getProperty('HAPICOM_TRIGGER_ID');
  if (!key || !trg) throw new Error('HAPICOM_API_KEY / HAPICOM_TRIGGER_ID が未設定です');

  // 注意: このエンドポイントは GET。POST だと 404 が返る。成功時は 204 No Content。
  var res = UrlFetchApp.fetch('https://alexa.hapicom.jp/tr/' + trg, {
    method: 'get',
    headers: { Authorization: 'Bearer ' + key },
    muteHttpExceptions: true
  });

  var code = res.getResponseCode();
  Logger.log('Alexa trigger: %s %s', code, res.getContentText());
  if (code < 200 || code >= 300) {
    throw new Error('Alexaトリガーの発火に失敗しました: HTTP ' + code);
  }

  clearTriggers_('fireAlexa'); // 使い終わったトリガーを掃除
}

APIキーとトリガーIDは、コードに直接書かずにスクリプトプロパティに登録しています。プロジェクトの設定画面から入れられます。

コード中のコメントは、詰まったときにAIが入れてくれたものです。今となっては一番大事な1行かもしれません。

6. 詰まったところ

コードを書いてもらった側なので偉そうなことは言えないのですが、それでも動くまでには多少かかりました。

6.1. カレンダーの予定が1件も見つからない

最初はCalendar.Events.list('primary', ...)と、primaryを指定する形で書いてもらっていました。自分のメインカレンダーを指すキーワードらしいです。

ところが、実行しても予定が1件も見つかりません。

原因は、GASを実行しているアカウントと、予定が入っているアカウントが違ったことでした。スクリプトは個人のGmailアカウントで作っていて、MTGの予定が入っているのは会社アカウントのカレンダーです。会社のカレンダーは個人アカウント側で「表示」しているだけなので、primaryはあくまで個人アカウントの、何も入っていないカレンダーを指していたわけですね。

画面上では同じカレンダーに全部並んで見えているので、しばらく気づきませんでした。これは自分の環境の話なので、AIには分かりようがない部分です。

そのあと、カレンダーIDを明示したら予定は見つかるようになったのですが、今度は中身が空っぽで、Meetリンクの判定が全部falseになりました。

こちらは共有時の権限が原因でした。カレンダーの共有設定に「予定の時間枠のみを表示(詳細は非表示)」という段階があって、この状態だと予定のタイトルもMeetリンクも読めません。APIから見るとaccessRolefreeBusyReaderになっているそうです。「予定の表示(すべての予定の詳細)」以上にする必要がありました。

どちらも「予定が取れていない」という同じ症状として出るので、切り分けができずに困りました。最終的には、実行アカウントとアクセス可能なカレンダーの権限を全部ログに吐く関数を書いてもらって、それを実行したら一発で分かりました。

6.2. Webhookを叩くと404が返る

一番時間がかかったのはここです。

幸いだったのは、Alexa側を先に作ってテストを通していたことです。仮想トリガーボタンを押せばAlexaが喋る、というところまでは、かなり早い段階で確認できていました。

つまり、仮想トリガー → Alexa の区間は無罪だと分かっている状態です。怪しいのは GAS → 仮想トリガー の1区間だけ。ここまで絞れているなら、あとはすぐだろうと思っていました。

ところが、GASから叩くと404が返ってきます。

404はURLが違うときのステータスなので、最初はトリガーIDの入力ミスを疑いました。APIキーとトリガーIDを取り違えて登録している可能性もあったので、そこも確認しました。ですが、どちらも合っています。

そこからは、AIに何度も書き直してもらいました。ヘッダの付け方が変わったり、リクエストの書き方ごと変わったりしたのですが、それでも404のままです。

結局、POSTとGETの両方を試してレスポンスを並べて出す関数を書いてもらって、そこでようやく分かりました。**このエンドポイントはGETでした。**POSTだと404、GETだと204 No Contentが返ります。

Webhookなのだから当然POSTだろう、という思い込みだけで時間を溶かしていたわけです。404を見た瞬間にURLの方ばかり疑ってしまいましたが、メソッド違いでも404は返ってくるんですね。

とはいえ、先に区間を絞れていたこと自体はやっぱり効いていました。もしAlexa側を後回しにしていたら、定型アクションの設定やスキルの有効化まで疑い始めて、もっと迷っていたと思います。

7. 参考コード

参考までに、実際のGoogle Apps Scriptのコード全文を貼っておきます。(CALENDAR_IDだけサンプルに置き換えています)

/**
 * その日の最初の Google Meet 会議の30分前に Alexa へ音声通知する
 *
 * 構成:
 *   毎朝 planDay() が走る → その日の最初のMeet会議を特定
 *   → 開始30分前に発火する1回限りのトリガーを作成
 *   → fireAlexa() が Webhook を叩き、Alexa定型アクションが発火
 *
 * 事前準備:
 *   1. Alexaアプリで「仮想トリガーボタン」スキルを有効化
 *   2. https://alexa.hapicom.jp/ でAPIキーとトリガーIDを取得
 *   3. Alexaアプリの定型アクションで、仮想トリガーボタン押下 → 好きな文言をアナウンス
 *   4. 下の SCRIPT PROPERTIES に APIキー / トリガーID を登録
 *   5. サービス「Google Calendar API」(識別子: Calendar) を追加
 *   6. setup() を1度だけ実行
 */

// ── 設定 ──────────────────────────────────────────────
var PLAN_HOUR   = 5;              // 毎朝この時刻に予定を確認 (JST)
var LEAD_MIN    = 30;             // 何分前に通知するか
// 注意: 'primary' はスクリプト実行アカウント(gmail)のカレンダーを指す。
var CALENDAR_ID = 'you@example.com';  // 自分のアカウントに置き換えてください
var TZ          = 'Asia/Tokyo';
// ─────────────────────────────────────────────────────

function setup() {
  clearTriggers_('planDay');
  ScriptApp.newTrigger('planDay')
    .timeBased()
    .atHour(PLAN_HOUR)
    .everyDays(1)
    .inTimezone(TZ)
    .create();
  Logger.log('毎日 %s時 に planDay を実行します', PLAN_HOUR);
  planDay(); // 今日の分もすぐ仕込む
}

function planDay() {
  clearTriggers_('fireAlexa');

  var ev = firstMeetEventToday_();
  if (!ev) {
    Logger.log('本日、Meetリンク付きの予定はありません');
    return;
  }

  var start = new Date(ev.start.dateTime);
  var fireAt = new Date(start.getTime() - LEAD_MIN * 60 * 1000);

  if (fireAt <= new Date()) {
    Logger.log('「%s」(%s) は既に%d分前を過ぎています。スキップ',
      ev.summary, start, LEAD_MIN);
    return;
  }

  ScriptApp.newTrigger('fireAlexa').timeBased().at(fireAt).create();
  Logger.log('「%s」開始 %s → %s に通知予定', ev.summary, start, fireAt);
}

/** 今日の予定のうち、Meetリンクを持つ最も早いものを返す */
function firstMeetEventToday_() {
  var now = new Date();
  var dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  var dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);

  var res = Calendar.Events.list(CALENDAR_ID, {
    timeMin: dayStart.toISOString(),
    timeMax: dayEnd.toISOString(),
    singleEvents: true,
    orderBy: 'startTime',
    maxResults: 100,
    timeZone: TZ
  });

  var items = res.items || [];
  for (var i = 0; i < items.length; i++) {
    var e = items[i];
    if (e.status === 'cancelled') continue;
    if (!e.start || !e.start.dateTime) continue;   // 終日予定は除外
    if (isDeclined_(e)) continue;                  // 自分が辞退した予定は除外
    if (hasMeet_(e)) return e;
  }
  return null;
}

function hasMeet_(e) {
  if (e.hangoutLink) return true;
  if (e.conferenceData && e.conferenceData.entryPoints) {
    for (var i = 0; i < e.conferenceData.entryPoints.length; i++) {
      var uri = e.conferenceData.entryPoints[i].uri || '';
      if (uri.indexOf('meet.google.com') !== -1) return true;
    }
  }
  var blob = (e.location || '') + ' ' + (e.description || '');
  return blob.indexOf('meet.google.com') !== -1;
}

function isDeclined_(e) {
  var at = e.attendees || [];
  for (var i = 0; i < at.length; i++) {
    if (at[i].self && at[i].responseStatus === 'declined') return true;
  }
  return false;
}

/** Webhookを叩いてAlexaの定型アクションを発火 */
function fireAlexa() {
  var props = PropertiesService.getScriptProperties();
  var key = props.getProperty('HAPICOM_API_KEY');
  var trg = props.getProperty('HAPICOM_TRIGGER_ID');
  if (!key || !trg) throw new Error('HAPICOM_API_KEY / HAPICOM_TRIGGER_ID が未設定です');

  // 注意: このエンドポイントは GET。POST だと 404 が返る。成功時は 204 No Content。
  var res = UrlFetchApp.fetch('https://alexa.hapicom.jp/tr/' + trg, {
    method: 'get',
    headers: { Authorization: 'Bearer ' + key },
    muteHttpExceptions: true
  });

  var code = res.getResponseCode();
  Logger.log('Alexa trigger: %s %s', code, res.getContentText());
  if (code < 200 || code >= 300) {
    throw new Error('Alexaトリガーの発火に失敗しました: HTTP ' + code);
  }

  clearTriggers_('fireAlexa'); // 使い終わったトリガーを掃除
}

// ══ 診断用 ════════════════════════════════════════════
// 問題が起きたらこれを実行し、ログをそのまま貼ってください。

/** どのGoogleアカウントで動いているか / 今日の予定が何件見えているか */
function diagnoseCalendar() {
  Logger.log('実行アカウント: %s', Session.getEffectiveUser().getEmail());

  var cals = Calendar.CalendarList.list({ maxResults: 50 }).items || [];
  Logger.log('--- アクセス可能なカレンダー (%s件) ---', cals.length);
  cals.forEach(function (c) {
    // accessRole が freeBusyReader だと予定の詳細もMeetリンクも読めない。
    // reader / writer / owner のいずれかが必要。
    Logger.log('  %s | 権限=%s%s', c.id, c.accessRole, c.primary ? ' ← primary' : '');
  });

  var now = new Date();
  var dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  var dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);

  var res = Calendar.Events.list(CALENDAR_ID, {
    timeMin: dayStart.toISOString(),
    timeMax: dayEnd.toISOString(),
    singleEvents: true,
    orderBy: 'startTime',
    maxResults: 100,
    timeZone: TZ
  });

  var items = res.items || [];
  Logger.log('--- %s の本日の予定 (%s件) ---', CALENDAR_ID, items.length);
  items.forEach(function (e) {
    Logger.log('  [%s] %s | Meet=%s | 辞退=%s',
      (e.start && e.start.dateTime) || '終日',
      e.summary, hasMeet_(e), isDeclined_(e));
  });
}

/** Webhookのレスポンスを詳しく見る (404の切り分け用) */
function diagnoseWebhook() {
  var props = PropertiesService.getScriptProperties();
  var key = props.getProperty('HAPICOM_API_KEY');
  var trg = props.getProperty('HAPICOM_TRIGGER_ID');

  Logger.log('TRIGGER_ID の長さ: %s', trg ? trg.length : 'null');
  Logger.log('TRIGGER_ID が "tr_" で始まる(=APIキーの誤入力の疑い): %s',
    trg ? trg.indexOf('tr_') === 0 : 'null');
  Logger.log('API_KEY の長さ: %s', key ? key.length : 'null');

  var url = 'https://alexa.hapicom.jp/tr/' + trg;
  Logger.log('叩くURL: %s', url);

  ['post', 'get'].forEach(function (m) {
    var res = UrlFetchApp.fetch(url, {
      method: m,
      headers: { Authorization: 'Bearer ' + key },
      muteHttpExceptions: true,
      followRedirects: true
    });
    Logger.log('%s → %s : %s', m.toUpperCase(),
      res.getResponseCode(), res.getContentText().slice(0, 300));
  });
}

function clearTriggers_(handlerName) {
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === handlerName) ScriptApp.deleteTrigger(t);
  });
}

8. 最後に

コード部分はAIに書いてもらったので、正直なところ細かい部分は今も分かっていません。ですが、何を作りたいのかと、どこがおかしいのかを切り分けるところは自分でやる必要があって、そこは多少頭を使ったなという感覚があります。

肝心の効果ですが、これは結構満足しています。運用初めて1ヶ月弱ですが、30分前に声が聞こえて、そのまま音楽が流れて、5分前にもう一度声がかかるので、ある程度準備をする時間も取れています。発表会に遅刻して以来、朝一のMTGは今のところ落としていません。

この記事が少しでも、同じように予定を見落としがちな方の参考となれば幸いです。


株式会社シンシア

株式会社シンシアでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。
※ シンシアにおける働き方の様子はこちら

弊社には年間100人程度の実務未経験の方に応募いただき、技術面接を実施しております。
この記事が少しでも学びになったという方は、ぜひ wantedly のストーリーもご覧いただけるととても嬉しいです!

14
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?