任意のGmailをChatGPTで解析して自動でGoogleカレンダーに追加するの巻

# 開発経緯
飛行機のフライト確定メールを見て、Googleカレンダーに予定を追加する時間を無くしたい。
今のAIであれば、メールをよしなに解析して登録するくらいのことはできるだろう。

しかし調査したところ、Google geminiの有料プランでなくてはこのGmail2GoogleCalenderの自動連携機能は使用できなかった。

金額的な話よりも、サブスクリプションが１つ増えることによる脳みそリソース圧迫の方が死活問題である。
というわけで、GASを用いて、GmailからchatGPTを経由して、GoogleCalenderにリクエストするシステムを作った。
（openaiに関しては、毎日バリバリに使っているのでサブスク契約していた）

同じようなことをしている人は他にもいたが、自分が連携したい送信元だけで、容易に連携先の追加が可能なことにこだわった

# 必須要件
- Gmailを解析して、Googleカレンダーに予定を追加する
- 簡単に新しい連携の追加が可能にしたい

# プログラム
```javascript
const CHATGPT_API_KEY = "API_KEY";
const CHATGPT_URL = "https://api.openai.com/v1/chat/completions";

// 作りたいカレンダーごとにtitleのフォーマットを変える
const ANA_PROMPT = "あなたは優秀なアシスタントです。以下のメールから予定情報を以下のJSON形式で抽出してください。ただしエスケープはしなくていいです。複数の場合は、この配列の中にカンマ区切りで指定してください。また、一つだけの場合でもこの配列で返却してください。絶対にjson以外回答しないでください。[{ \"title\"\: \"〇〇空港→〇〇空港\", \"start\": \"YYYY-MM-DDTHH:mm\", \"end\": \"YYYY-MM-DDTHH:mm\" }] メール本文:";

// ここにメール検索のパターンを追加していく: queryはメールの検索。handlerは専用関数名
const ROUTES = [
  {
    label: "ANAフライト",
    query: 'ANA お支払い完了 予約',
    handler: handleAnaFlight
  }
];

/**
 * ANAフライト予約
 * 
 * 件名フィルターで検索結果から厳密にフィルタリングする。個別に色設定ができる
 */
function handleAnaFlight(message) {
  const subject = message.getSubject();
  
  // 件名フィルター
  if (!subject.includes("ANA") || !subject.includes("お支払い完了のお知らせ")) {
    Logger.log(`スキップ: ${subject} は対象外`);
    return;
  }

  const body = message.getPlainBody();
  const json = callChatGPT(ANA_PROMPT, body);

  for (const item of json) {
    createCalendarEvent(item, CalendarApp.EventColor.BLUE);
  }
}

/**
 * メイン関数
 * 
 * トリガーを１時間に1回実行する前提で常に１時間前までのメールに検索をかける
 */
function autoSchedule() {
  Logger.log(CalendarApp.getAllCalendars()) 
  for (const route of ROUTES) {

    // 1時間に一回実行させる
    const date = new Date();
    date.setHours(date.getHours() - 1);
    const formattedDate = Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy/MM/dd');

    // // 各ルートごとの検索実行
    const threads = GmailApp.search(`${route.query} after:${formattedDate}`);
    const messages = GmailApp.getMessagesForThreads(threads);

    Logger.log(messages.length+"件");

    for (const thread of messages) {
      for (const message of thread) {
        route.handler(message);
        // レートリミット回避
        Utilities.sleep(1);
      }
    }
  }
}

/**
 * chatgptに問い合わせ
 * 
 * content: プロンプト
 * body: メール本文
 */
function callChatGPT(content, body) {

  const payload = {
    "model": "gpt-4o",
    "messages": [{
      "role": "user",
      "content": `${content} ${body}`
    }],
    "max_tokens": 500
  };

  const options = {
    "method": "post",
    "headers": {
      "Authorization": `Bearer ${CHATGPT_API_KEY}`,
      "Content-Type": "application/json"
    },
    "payload": JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch(CHATGPT_URL, options);
  const json = JSON.parse(response.getContentText());
  let gptReply = json.choices[0].message.content;

  // コードブロック除去。gptはjsonで返答する場合でも必ずコードブロックで整形してしまうため。
  gptReply = gptReply.replace(/```json|```/g, '').trim();

  try {
    return JSON.parse(gptReply);
  } catch (e) {
    Logger.log(`GPTのレスポンスが不正: ${gptReply}`);
    throw new Error("GPT JSONパース失敗");
  }
}

/**
 * カレンダーに追加
 * 
 * TODO: 説明の追加
 */
function createCalendarEvent(details, color) {

  if (!details || !details.title || !details.start || !details.end) {
    Logger.log("スキップ: detailsの中身が不完全");
    return;
  }

  const calendar = CalendarApp.getDefaultCalendar();
  const now = new Date();
  const startDate = new Date(details.start);
  const endDate = new Date(details.end);

  // 日時不正
  if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
    Logger.log("スキップ: 日付形式が不正");
    return;
  }

  // 未来の日付のみ追加する
  if (startDate < now) {
    Logger.log(`スキップ: 過去の予定 - ${details.title}`);
    return;
  }

  // 日時反転
  if (startDate >= endDate) {
    Logger.log(`スキップ: 終了が開始より前 - ${details.title}`);
    return;
  }

  // 重複チェック
  const events = calendar.getEvents(startDate, endDate, {search: details.title});
  if (events.length > 0) {
    Logger.log(`イベント「${details.title}」は既に存在するのでスキップ`);
    return;
  }

  // イベント作成
  const event = calendar.createEvent(details.title, startDate, endDate);
  event.setColor(color);
  Logger.log(`イベント作成: ${details.title}, 開始: ${startDate.toISOString()}, 終了: ${endDate.toISOString()}, イベントID: ${event.getId()}`);
}

```


OPEN APIのAPIキーをCHATGPT_API_KEYに設定してください。
この設定の仕方は、たくさん情報あると思うので割愛します。

# 連携先追加方法
ROUTESにjson形式で、メールの検索クエリと専用関数を定義します。
次に追加した専用関数を「handleAnaFlight」を参考に追加します。
件名フィルターとは、Gmailのクエリ検索では機能が柔軟であるがゆえにほかの関係ないメールも検索結果に引っかかってしまうため、ここで厳密なフィルタリング処理を行っています。
私は、まずANAの支払い後の予約確定メールからカレンダー追加したいので、handleAnaFlightを作りました。
後続の処理は同じです。

あと重要なのが、ANA_PROMPTです。
ANA_PROMPTを参考に他の通知を追加する場合、プロンプトを組み直します。
連携先個別にプロンプトを設定できる方がいいかなと思いました。


# GASの設定
１時間に一回トリガー実行するように設定して、過去１時間のメールを探索します。
これでもれなくメールを調査して、過去の無駄なメールを捜査することを防ぎます。


# 困ったこと
私はgoogleアカウントを２つ使い分けています。
ANAのメールはAアカウントに届きますが、GoogleカレンダーはBアカウントで利用していました。

API連携どうするかとかごちゃごちゃトライしていたのですが、
そんなことしなくても、Googleカレンダーをアカウント間で連携して仕舞えば関係ありませんでした笑

# まとめ
chatGPTを使って、GmailからGoogleCalederに予定を自動作成するシステムを作りました。
これでひとまずgeminiの契約はしなくてよさそうです。
２時間くらいでぱぱっと作ったものなので、今後よりエラーハンドリングや機能追加など検討します
ありがとうございました。