生成AIを使ってメールからこんな感じでカレンダーに登録されるようにしました。記事の後半にソースコード全体を載せています。

背景
生活の中で歯医者・レストラン・演劇など予約の確認メールがGmailに届くことが多くあります。これらのメールがメールボックスにたまると手動でGoogleカレンダーに転記するのも面倒になります。生成AIを使ってこれを自動化できれば気持ちよく生活することができそうです。交通チケットでは機能が実装されていて、類似の挙動を実現したいです。

やりたいこと
- Gmailに予約確認メールが届くと自動でカレンダーに登録し、登録したメールはアーカイブしたい。
- 不具合が起きたときに人力で復旧できるようにしておきたい。
実装
処理の流れ
- GASで一定時間ごとに新着メールを処理する
- OpenAI APIで予定に関係あるメールかを判定する
- OpenAI APIで予定情報を構造化する
- カレンダーに登録する
- 処理済みのメールにラベルをつけアーカイブする
プロンプト
下記がプロンプトの和訳です。演劇の場合は当日現金精算が必要かどうかをカレンダーに入れてほしいです。
あなたは、イベントのタイトル、開場時間、開始時間と終了時間、会場、支払いの詳細、その他の重要な参加者情報などのイベント情報をメールの内容から抽出するアシスタントです。
以下のメールの内容から次の情報を抽出します。メールの内容に年が明示的に記載されていない場合は、提供されているメール受信日 (${receivedDate}) を使用して年を推測します。
- イベントのタイトル: パフォーマンスまたはシリーズの名前。どちらもない場合は、会場名を使用します。
- 開場時間: 可能な場合は、「YYYY-MM-DD HH:MM:SS」形式で提供してください。
- イベントの開始時間: 「YYYY-MM-DD HH:MM:SS」形式で提供してください。
- 終了時間: 「YYYY-MM-DD HH:MM:SS」形式で提供してください。終了時間が指定されていない場合は、開始時間の 2 時間後に設定してください
- 会場
- 支払い要件: 次のいずれかを指定します:
- 当日に支払いが必要な場合は「当日精算(XXX円)」(「XXX円」は指定されている場合の金額)
- 支払いがすでに完了している場合は「事前精算済み」
- 支払いの詳細が不明または指定されていない場合は「不明」
- 注記: キャンセル ポリシー、持参するもの、準備資料、イベント当日に接続するための URL など、参加者が知っておくべき重要な情報について、日本語で簡単に要約してください。内容全体をコピーせず、要約のみにしてください。アクションが必要な場合は、参加者に元のメールを確認するようアドバイスしてください。
工夫したところ
- 無理やりイベントの体にされるのを避けるため、タイトルだけで予定に関係のあるものかを判定させる
https://mail.google.com/mail/u/0/#inbox/${messageId}のリンクで元メールへのリンクをつけておく- 元メールを確認させて良いという指示を入れたほうが要約もちょうどよくなりました
- メールの受信日時を渡すことで、何年のイベントか推測しやすくする
- メインのカレンダーとは別のカレンダーから招待を送ることで、自動で登録されていることを分かりやすくする
セットアップ
calendaredというラベルを作成する- 「プロジェクトの設定」からスクリプトプロパティを設定する
- 下記のソースコードを保存する
- タイマーを5分ごとに設定する
ソースコード
function addTheaterEventsToCalendar() { var query = `(観劇 OR 公演 OR チケット OR 予約 OR 開演 OR 申し込み OR 申込) newer_than:2d category:primary -label:calendared`; Logger.log(`クエリ: ${query}`); var threads = GmailApp.search(query); var calendarId = PropertiesService.getScriptProperties().getProperty('CALENDAR_ID'); var calendar = CalendarApp.getCalendarById(calendarId); threads.forEach(thread => { var messages = thread.getMessages(); messages.forEach(message => { var subject = message.getSubject(); var body = message.getPlainBody(); // 予定に関係ないメールは処理を終了する if (!isEventRelatedEmail(subject)) { Logger.log("%sは予定に関係の無いメールと判断されました。", subject); return; } var eventData = extractEventInfoFromAPI(subject, body); if (!eventData || !eventData.start || !eventData.end) { return; } var descriptions = []; if (eventData.opening) { descriptions.push(`開場時刻: ${eventData.opening}`); } if (eventData.venue) { descriptions.push(`会場: ${eventData.venue}`); } if (eventData.paymentRequired) { descriptions.push(`精算: ${eventData.paymentRequired}`); } if (eventData.notes) { descriptions.push(`notes: ${eventData.notes}`); } var messageId = message.getId(); var eventDescription = `${descriptions.join("\n")}\n\nsource mail: https://mail.google.com/mail/u/0/#inbox/${messageId}`; Logger.log(JSON.stringify(eventDescription)); var event = calendar.createEvent(eventData.title, new Date(eventData.start), new Date(eventData.end), { description: eventDescription, location: eventData.venue }); var mailAddress = PropertiesService.getScriptProperties().getProperty('OWNER_MAIL_ADDRESS'); event.addGuest(mailAddress); var label = GmailApp.getUserLabelByName("calendared") || GmailApp.createLabel("calendared"); thread.addLabel(label); thread.moveToArchive(); }); }); } function isEventRelatedEmail(subject) { var apiUrl = "https://api.openai.com/v1/chat/completions"; var apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY'); var messages = [ { role: "system", content: "You are an assistant that determines if an email is related to an event or appointment based on the subject line." }, { role: "user", content: `Is the following subject line related to an event or appointment? Please respond with 'true' for yes and 'false' for no. Subject: "${subject}"` } ]; var functions = [ { name: "isEventRelated", description: "Determines if the subject line is related to an event or appointment.", parameters: { type: "object", properties: { result: { type: "boolean", description: "True if the email is related to an event or appointment, false otherwise" } }, required: ["result"] } } ]; var payload = { model: "gpt-4o-mini", messages: messages, functions: functions, function_call: { "name": "isEventRelated" }, max_tokens: 200 }; var options = { method: "POST", headers: { "Authorization": "Bearer " + apiKey, "Content-Type": "application/json" }, payload: JSON.stringify(payload) }; var response = UrlFetchApp.fetch(apiUrl, options); var json = JSON.parse(response.getContentText()); var functionResult = json.choices[0].message.function_call.arguments; try { var result = JSON.parse(functionResult).result; } catch (e) { Logger.log(functionResult) Logger.log(e) } return result; } function extractEventInfoFromAPI(subject, body, receivedDate) { var apiUrl = "https://api.openai.com/v1/chat/completions"; var apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY'); var messages = [ { role: "system", content: "You are an assistant that extracts event information such as the event title, opening time, start and end times, venue, payment details, and other important participant information from email content." }, { role: "user", content: `Extract the following information from the email content below. Use the provided email received date (${receivedDate}) to infer the year if it is not explicitly stated in the email content: - Event title: The name of the performance or series, or if neither is available, use the venue name - Opening time: Please provide in 'YYYY-MM-DD HH:MM:SS' format, if available - Start time of the event: Please provide in 'YYYY-MM-DD HH:MM:SS' format - End time: Please provide in 'YYYY-MM-DD HH:MM:SS' format. If the end time is not mentioned, set it to 2 hours after the start time - Venue - Payment requirements: Specify as one of the following: * '当日精算(XXX円)' if payment is required on the day, where 'XXX円' is the amount if specified * '事前精算済み' if payment has already been completed * '不明' if the payment details are unclear or not mentioned - Notes: Provide a brief summary of any important information participants should know, such as cancellation policy, items to bring, preparation materials, or any URLs for connecting on the day of the event in Japanese. Do not copy the entire content, just summarize. If action is required, advise the participant to check the original email. Email content: "${subject}\n${body}"` } ]; var functions = [ { name: "extractEventInfo", description: "Extracts event details from email content.", parameters: { type: "object", properties: { title: { type: "string", description: "Event title based on performance name, series name, or venue name" }, opening: { type: "string", description: "Opening time in 'YYYY-MM-DD HH:MM:SS' format, if available" }, start: { type: "string", description: "Start time in 'YYYY-MM-DD HH:MM:SS' format" }, end: { type: "string", description: "End time in 'YYYY-MM-DD HH:MM:SS' format" }, venue: { type: "string", description: "The venue name" }, paymentRequired: { type: "string", description: "Payment requirements" }, notes: { type: "string", description: "Any other important information participants should know, such as cancellation policy, items to bring, preparation materials, or any URLs for connecting on the day of the event" } }, required: ["title"] } } ]; var payload = { model: "gpt-4o-mini", messages: messages, functions: functions, function_call: { "name": "extractEventInfo" }, max_tokens: 1000 }; var options = { method: "POST", headers: { "Authorization": "Bearer " + apiKey, "Content-Type": "application/json" }, payload: JSON.stringify(payload) }; var response = UrlFetchApp.fetch(apiUrl, options); var json = JSON.parse(response.getContentText()); // 関数の結果を取り出し var functionResult = json.choices[0].message.function_call.arguments; var eventInfo = JSON.parse(functionResult); return { title: eventInfo.title, opening: eventInfo.opening, start: eventInfo.start, end: eventInfo.end, venue: eventInfo.venue, paymentRequired: eventInfo.paymentRequired, notes: eventInfo.notes }; }
頼む!
人間がその時その場にいるために必要な情報は1個のメールに全部の情報を(画像ではなく)文字で入れてください。
- イベント名
- 開場時刻(年月日を含む)
- 開演時刻
- 終了時刻
- 会場
- 注意事項
