😺

猫の手も借りたいのでGeminiさんの手を借りてみた

2024/01/02に公開

猫の手も借りたいのでGeminiさんの手を借りてみた

はじめに: Woerkspace add-onからGeminiを呼んでみました

スケジュール管理の新時代へようこそ

「ああ、忙しい日々の中で、予定を調整もままならない。。。猫の手でも借りたいくらいだ」と思ったことはありませんか?
そんな願いをかなえるため、Google Apps Scriptを駆使して、Google CalendarとEmailを統合する独創的なツールを作ってみました。
このツールは、あなたのデジタル秘書として、次のような機能を提供します:

  1. 直感的なカレンダー予定調整:
    このアドオンを使えば、Google CalendarやEmail画面から簡単にミーティングやイベントの予定調整が可能になります。時間を見つける苦労から解放され、スケジュール管理のストレスを軽減できます。
  2. AI(Gemini pro)による返信文面の自動生成:
    AI技術(Gemini pro)を活用し、ユーザーのニーズに合わせて最適なミーティング日程を提案し、それに応じた返信文も生成します。もう返信に悩むことはありません。提案された文面をコピーして、すぐに返信が完了します。

このアドオンは、まるであなたの個人秘書のように、日々のスケジュール管理をサポートし、あなたが本来集中すべき仕事に専念できるように手助けします。この記事では、この革新的なツールがどのようにして生まれたのか、その機能と利用方法を詳細に解説していきます。

80979 17958 81159 9743

まずは秘書の働きっぷりをご紹介

このセクションでは、Google Apps Scriptに基づくカレンダーとメール統合ツールを、短い動画と画像を通じて紹介します。これらのビジュアルコンテンツは、ツールの使い方とその魅力をより直感的に理解していただくために用意しました。

動画で見るツールのデモンストレーション

まずは、この短いデモ動画をご覧ください。ツールの基本的な機能や操作方法を、実際の画面を使って紹介しています。動画では、ユーザーがどのようにしてカレンダー予定を調整し、AIによる返信文面を生成するかを示しています。

8

画像で見るツールのインターフェース

次に、ツールの主要な画面と機能をいくつかの画像で紹介します。これらの画像は、ツールの直感的なユーザーインターフェースと、AIによる提案がどのように表示されるかを具体的に示しています。

MTG情報を入れて、一番下のボタンをポチッとな(何故かボタンの色が変えられず・・・)


1

空いているMTGの枠を調べて、お返事の例を作ってくれます
2

アドオンに関しては下記の記事を参考にしました。感謝です。

https://link-and-motivation.hatenablog.com/entry/2023/07/21/120000

https://developers.google.com/apps-script/add-ons/overview?hl=ja

Get Started: まずは3分で使い始めてみましょう!

このセクションでは、クイックスタートの手順を記載します
・コードをGoogle App Scriptに貼り付ける
・Goole AI StuidoのAPIキーの取得と設定
・テストデプロイの方法

1. コードをGoogle App Scriptに貼り付ける

以下のコードをGoogle Apps Scriptのコンソールにコードをコピーしてください。

ソースコード
appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
  },
  "exceptionLogging": "STACKDRIVER",
  "oauthScopes": [
    "https://www.googleapis.com/auth/calendar.readonly",
    "https://www.googleapis.com/auth/script.locale",
    "https://www.googleapis.com/auth/calendar.addons.execute",
    "https://www.googleapis.com/auth/calendar",
    "https://www.googleapis.com/auth/cloud-platform",
    "https://www.googleapis.com/auth/cloud-platform.read-only",
    "https://www.googleapis.com/auth/script.external_request",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/script.send_mail",
    "https://www.googleapis.com/auth/gmail.addons.execute"
  ],
  "runtimeVersion": "V8",
  "addOns": {
    "common": {
      "name": "秘書の猫蔵さん",
      "logoUrl": "https://firebasestorage.googleapis.com/v0/b/meetingsage-ai.appspot.com/o/images%2FService_icon.jpeg?alt=media&token=0add7cd1-b999-4a40-af36-03e1e40e9b33",
      "layoutProperties": {
        "primaryColor": "#2762EC",
        "secondaryColor": "#2762EC"
      },
      "homepageTrigger": {
        "runFunction": "onOpen"
      },
      "useLocaleFromApp": true
    },
    "calendar": {},
    "gmail": {}
  }
}

コード.gs
const GOOGLE_AI_STUDIO_API_KEY = PropertiesService.getScriptProperties().getProperty("googleAiStudioApiKey")
const GEMINI_ENDPOINT_FROM_GOOGLE_AI_STUDIO = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${GOOGLE_AI_STUDIO_API_KEY}`;

function onOpen(e) {
  const cardBuilder = CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader().setTitle('空き時間確認'));

  // ミーティングの所要時間を選択するラジオボタンを含むセクション
  const durationRadioButtons = CardService.newSelectionInput()
    .setType(CardService.SelectionInputType.RADIO_BUTTON)
    .setTitle("ミーティングの所要時間を選択してください")
    .setFieldName("meetingDuration")
    .addItem("30分", "30", false)
    .addItem("1時間", "60", true) // デフォルト選択を「1時間」に設定
    .addItem("1時間半", "90", false)
    .addItem("2時間", "120", false);

  const prioritySection = CardService.newSelectionInput()
    .setType(CardService.SelectionInputType.RADIO_BUTTON)
    .setTitle("優先度を選択してください")
    .setFieldName("priority")
    .addItem("緊急(時間外含む)", "urgent", false)
    .addItem("高(7日以内)", "high", false)
    .addItem("中(14日以内)", "medium", true)
    .addItem("低(30日以内)", "low", false);

  const userEmailInput = CardService.newTextInput()
    .setFieldName("userEmails")
    .setTitle("他のユーザーのメールアドレス(`,`区切り)")
    .setSuggestionsAction(CardService.newAction()
      .setFunctionName("onUserEmailsInput"));

  const section = CardService.newCardSection()
    .addWidget(durationRadioButtons)
    .addWidget(prioritySection)
    .addWidget(userEmailInput)
    .addWidget(
      CardService.newButtonSet().addButton(
      CardService.newTextButton()
        .setText('予定を確認')
        .setTextButtonStyle(CardService.TextButtonStyle.FILLED)
        .setOnClickAction(CardService.newAction().setFunctionName('getFreeTimes'))
    ));
  cardBuilder.addSection(section);

  // 完成したカードをビルド
  const card = cardBuilder.build();

  return card;
}


function onUserEmailsInput(e) {
  const userInput = e.formInput.userEmails; // ユーザーが入力したテキストを取得
  const suggestions = CardService.newSuggestions();

  // ここでユーザーの入力に基づいて候補を生成する
  // 例: ドメイン内のユーザーのメールアドレスをフィルタリングして提示
  const filteredUserEmails = getFilteredUserEmails(userInput); // この関数はユーザーの入力に基づいて候補を返す
  filteredUserEmails.forEach(email => {
    suggestions.addSuggestion(email);
  });

  return CardService.newSuggestionsResponseBuilder()
    .setSuggestions(suggestions)
    .build();
}

function getHolidays(startDate, endDate) {
  const HOLIDAYS_CALENDAR_ID = 'ja.japanese#holiday@group.v.calendar.google.com';
  getCalendarEvents(HOLIDAYS_CALENDAR_ID ) 
  const holidayCalendar = CalendarApp.getCalendarById(HOLIDAYS_CALENDAR_ID);
  if (!holidayCalendar) {
    throw new Error(`祝日カレンダーが見つかりません: ${HOLIDAYS_CALENDAR_ID}`);
  }
  const holidays = holidayCalendar.getEvents(startDate, endDate);
  return holidays.map(event => event.getStartTime());
}


function getAvailableTimes(meetingDuration, result) {
  const now = new Date();
  const endDate = new Date();
  endDate.setDate(now.getDate() + result.days);

  const calendar = CalendarApp.getDefaultCalendar();
  const freeTimes = [];

  // 祝日をセットに変換
  const holidays = getHolidays(now, endDate).reduce((map, holiday) => {
    map[holiday.toDateString()] = true;
    return map;
  }, {});

  for (let day = now; day < endDate; day.setDate(day.getDate() + 1)) {
    const dayStart = new Date(day);
    const dayEnd = new Date(day);

    // 祝日または週末の場合はスキップ
    const dayOfWeek = dayStart.getDay();
    if (holidays[dayStart.toDateString()] || dayOfWeek === 0 || dayOfWeek === 6) {
      continue;
    }

    dayStart.setHours(8, 0, 0, 0);
    dayEnd.setHours(20, 0, 0, 0);

    const events = calendar.getEvents(dayStart, dayEnd);

    // 空き時間のチェック
    for (let hour = 8; hour < 20; hour++) {
      for (let minutes = 0; minutes < 60; minutes += meetingDuration) {
        dayStart.setHours(hour, minutes, 0, 0);
        dayEnd.setHours(hour, minutes + meetingDuration, 0, 0);

        const isFree = !events.some(event => {
          return event.getStartTime() < dayEnd && event.getEndTime() > dayStart && !event.isAllDayEvent();
        });

        if (isFree) {
          freeTimes.push({ date: new Date(dayStart) });
        }
      }
    }
  }

  return freeTimes.map(time => {
    return Utilities.formatDate(time.date, Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm');
  }).join('\n');
}



function getMemberAvailableTimes(calendarId, meetingDuration, result) {
  const now = new Date();
  const endDate = new Date();
  endDate.setDate(now.getDate() + result.days);
  subscribeToCalendar(calendarId); // カレンダーを購読

  const calendar = CalendarApp.getCalendarById(calendarId);
  if (!calendar) {
    throw new Error(`指定されたカレンダーが見つかりません: ${calendarId}`);
  }

  const freeTimes = [];
  const holidays = getHolidays(now, endDate).reduce((map, holiday) => {
    map[holiday.toDateString()] = true;
    return map;
  }, {});

  for (let day = now; day < endDate; day.setDate(day.getDate() + 1)) {
    const dayStart = new Date(day);
    const dayEnd = new Date(day);

    // 祝日または週末の場合はスキップ
    const dayOfWeek = dayStart.getDay();
    if (holidays[dayStart.toDateString()] || dayOfWeek === 0 || dayOfWeek === 6) {
      continue;
    }

    dayStart.setHours(8, 0, 0, 0);
    dayEnd.setHours(20, 0, 0, 0);

    const events = calendar.getEvents(dayStart, dayEnd);

    for (let hour = 8; hour < 20; hour++) {
      for (let minutes = 0; minutes < 60; minutes += meetingDuration) {
        dayStart.setHours(hour, minutes, 0, 0);
        dayEnd.setHours(hour, minutes + meetingDuration, 0, 0);

        const isFree = !events.some(event => {
          return event.getStartTime() < dayEnd && event.getEndTime() > dayStart && !event.isAllDayEvent();
        });

        if (isFree) {
          freeTimes.push({ date: new Date(dayStart) });
        }
      }
    }
  }

  return freeTimes.map(time => {
    return Utilities.formatDate(time.date, Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm');
  }).join('\n');
}

function getFreeTimes(e) {
  const duration = parseInt(e.formInputs.meetingDuration[0], 10); // ミーティング時間を数値に変換
  const priority = e.formInputs.priority[0]; // 優先度を取得
  const otherUserEmails = e.formInputs.userEmails ? e.formInputs.userEmails[0] : null; // 他のユーザーのメールアドレスを取得

  // 優先度に基づいて利用可能なビジネス時間を取得
  const freeTimes = getAvailableBusinessTimes(duration, priority, otherUserEmails);

  const card = CardService.newCardBuilder();
  card.setHeader(CardService.newCardHeader().setTitle('空き時間'));

  const action = CardService.newAction()
    .setFunctionName('getAIProposal')
    .setParameters({
      freeTimes: JSON.stringify(freeTimes),
      duration: duration.toString() // ミーティング時間もパラメータに追加
    });

  // AIの提案を受けるボタンを追加する新しいセクション
  const proposalButtonSection = CardService.newCardSection()
    .addWidget(
      CardService.newTextButton()
        .setText('秘書さんの提案を受ける')
        .setTextButtonStyle(CardService.TextButtonStyle.FILLED)
        .setOnClickAction(action)
    );

  // 空き時間の結果を表示するセクション
  const freeTimesSection = CardService.newCardSection();
  freeTimesSection.addWidget(CardService.newTextParagraph().setText(freeTimes));

  // カードにセクションを追加
  card.addSection(proposalButtonSection)
    .addSection(freeTimesSection);

  return CardService.newActionResponseBuilder()
      .setNavigation(CardService.newNavigation().pushCard(card.build()))
      .build();
}


function getAvailableBusinessTimes(meetingDuration, priority, otherUserEmails) {
  const result = getDaysByPriority(priority);
  const myAvailableTimes = getAvailableTimes(meetingDuration, result).split('\n');

  let commonFreeTimes = [...myAvailableTimes]; // スプレッド演算子を使用してコピーを作成

  // 他のユーザーの予定を取得し、共通の空き時間を見つける
  if (otherUserEmails) {
    const otherUserEmailsArray = otherUserEmails.split(',').map(email => email.trim());
    otherUserEmailsArray.forEach(email => {
      const memberAvailableTimes = getMemberAvailableTimes(email, meetingDuration, result);
      commonFreeTimes = findCommonFreeTimes(commonFreeTimes, memberAvailableTimes);
    });
  }

  // ビジネス時間内のみをフィルタリング
  const businessHoursStart = result.isUrgent ? 8 : 9;
  const businessHoursEnd = result.isUrgent ? 20 : 18;
  
  const filteredFreeTimes = filterBusinessHours(commonFreeTimes, businessHoursStart, businessHoursEnd);
  
  return filteredFreeTimes;
}


function filterBusinessHours(times, startHour, endHour) {
  return times.filter(timeString => {
    const [datePart, timePart] = timeString.split(' '); // 日付と時刻を分割
    const [hour, minutes] = timePart.split(':').map(num => parseInt(num, 10));
    return hour >= startHour && (hour < endHour || (hour === endHour && minutes === 0));
  }).join('\n');
}


function getDaysByPriority(priority) {
  let days = 14; // デフォルトの日数
  let isUrgent = false; // デフォルトの緊急フラグ

  switch (priority) {
    case 'urgent':
      days = 7; // 緊急: 7日以内
      isUrgent = true; // 緊急フラグをオンにする
      break;
    case 'high':
      days = 7; // 高: 7日以内
      break;
    case 'medium':
      days = 14; // 中: 14日以内
      break;
    case 'low':
      days = 30; // 低: 30日以内
      break;
  }

  return { days, isUrgent };
}


function getAIProposal(e) {
  const freeTimesString = e.parameters.freeTimes;
  const durationString = e.parameters.duration;

  if (!freeTimesString) {
    return CardService.newActionResponseBuilder()
      .setNavigation(CardService.newNavigation().popToRoot())
      .build();
  }

  // JSON文字列を配列に変換
  const freeTimes = freeTimesString.split('\n');
  const res = callAi(freeTimes, durationString);

  // 空いている時間の一覧を表示
  const proposalContent = res;

  const proposalCard = CardService.newCardBuilder()
    .setHeader(CardService.newCardHeader().setTitle('返信文の提案'))
    .addSection(
      CardService.newCardSection().addWidget(
        CardService.newTextParagraph().setText(proposalContent)
      )
    );

  return CardService.newActionResponseBuilder()
    .setNavigation(CardService.newNavigation().pushCard(proposalCard.build()))
    .build();
}


function callAi(freeTimesData, time) {
  const token = getToken();
  const prompt = `下記の情報を元に、MTGの候補になる日を3つ選び、それを返信用に文面を考えてそのまま使えるように返してください。これはビジネス用のメールです。冒頭は「お世話になっております」として、最後は「よろしくお願いします。」で終わってください。会議時間は${time}分です。返信分には何時から何時までかを必ずきちんと記載してください。`;
  const text = `${prompt}${freeTimesData}`;
  // return callGemini(text, token);
  return callGeminiFormGooleAiStudio(text, GOOGLE_AI_STUDIO_API_KEY)
}

function getCalendarEvents(calendarId) {
  try {
    CalendarApp.subscribeToCalendar(calendarId);
    Logger.log('カレンダーに登録されました: ' + calendarId);
  } catch (e) {
    Logger.log('カレンダーの登録に失敗しました: ' + e.message);
    return null;
  }

  const calendar = CalendarApp.getCalendarById(calendarId);

  if (!calendar) {
    Logger.log('カレンダーが見つかりません: ' + calendarId);
    return null;
  }

  const today = new Date();
  const endDate = new Date();
  endDate.setDate(today.getDate() + 30);

  const events = calendar.getEvents(today, endDate);

  return events.map(event => ({
    title: event.getTitle(),
    startTime: event.getStartTime(),
    endTime: event.getEndTime()
  }));
}

function subscribeToCalendar(calendarId) {
  try {
    CalendarApp.subscribeToCalendar(calendarId);
    Logger.log('カレンダーに登録されました: ' + calendarId);
  } catch (e) {
    Logger.log('カレンダーの登録に失敗しました: ' + e.message);
  }
}

function unsubscribeFromCalendar(calendarId) {
  try {
    const calendar = CalendarApp.getCalendarById(calendarId);
    calendar.unsubscribeFromCalendar();
    Logger.log('カレンダーの購読が解除されました: ' + calendarId);
  } catch (e) {
    Logger.log('カレンダーの購読解除に失敗しました: ' + e.message);
  }
}

function findCommonFreeTimes(myAvailableTimes, memberAvailableTimes) {
  const commonTimes = myAvailableTimes.filter(time => memberAvailableTimes.includes(time));
  return commonTimes;
}

function setPrompt(prompt) {
  return {
    "contents": [
        {
            "role": "user",
            "parts": [
                {
                    "text": prompt
                }
            ]
        }
    ],
    "generation_config": {
        "maxOutputTokens": 1024,
        "temperature": 0,
        "topP": 1
    }
  }
}

function getToken() {
  return ScriptApp.getOAuthToken();
}

function callGeminiFormGooleAiStudio(text,){
  const headers = {
    "Content-Type": "application/json"
  };

  const payload = setPrompt(text);

  const options = {
    "method": "POST",
    "headers": headers,
    "payload": JSON.stringify(payload)
  };

  // link: https://makersuite.google.com/app/prompts/new_freeform
  const response = UrlFetchApp.fetch(GEMINI_ENDPOINT_FROM_GOOGLE_AI_STUDIO, options);
  const responseData = JSON.parse(response.getContentText());
  // candidates で返ってきているテキストを全て繋ぎ合わせる
  
  let returnText = "";
  for (const candidate of responseData.candidates) {
  if (candidate.content.parts && candidate.content.parts[0] && candidate.content.parts[0].text) {
    returnText += candidate.content.parts[0].text;
  }
}
console.log(returnText);
  return returnText;
}

function callGemini(text, token) {
  const headers = {
    "Content-Type": "application/json",
    "Authorization": "Bearer " + token,
  };

  const payload = setPrompt(text);

  const options = {
    "method": "POST",
    "headers": headers,
    "payload": JSON.stringify(payload)
  };

  const response = UrlFetchApp.fetch(GEMINI_ENDPOINT, options);
  const responseData = JSON.parse(response.getContentText());
  // candidates で返ってきているテキストを全て繋ぎ合わせる
  let returnText = "";
  for(let data of responseData) {
    returnText += data.candidates[0].content.parts[0].text;
  }
  console.log(returnText);
  return returnText;
}

function run() {
  const token = getToken();
  const text = "メールの文章を考えてください";
  
  console.log(callGeminiFormGooleAiStudio(text, GOOGLE_AI_STUDIO_API_KEY));
}

function sendDebugEmail(subject, body) {
  var recipient = Session.getActiveUser().getEmail(); // 自分自身のメールアドレスを取得
  MailApp.sendEmail(recipient, subject, body);
}
function sendEmailTest(){
var subject = 'デバッグ情報';
var body = "freeTimesString"
sendDebugEmail(subject, body)
}
function getFilteredUserEmails(input) {
    // ここでinputに基づいて候補をフィルタリングする
    var allUserEmails = ['user1@test.com']
    return allUserEmails.filter(function(email) {
      return email.includes(input);
    });
  }
  • GitHubリポジトリを開き、必要なスクリプトファイルを見つけます。
  • ファイルの内容をコピーします。
  • Google Apps Scriptのコンソールを開き、新しいプロジェクトを作成します。
  • コピーしたコードを新しいスクリプトファイルに貼り付けます。

2. APIキーの取得と設定

Create API Keyを押下して、新しくキーを取得します。
https://makersuite.google.com/app/apikey

3

無料GWSユーザーの場合:

  • Google AI Studioを訪れてAPI_KEYを取得します。
  • Google Apps Scriptのコンソールで、スクリプトのプロパティにAPIキーを設定します。
    • 「ファイル」>「プロジェクトのプロパティ」>「スクリプトのプロパティ」に移動します。
    • 新しいプロパティとしてAPIキーを追加します。
      4

Google CloudまたはGWS導入企業の場合:

  • Vertex AIにアクセスし、Genimi APIの利用設定を行います。
  • 同様に、Google Apps ScriptのコンソールでAPIキーをスクリプトのプロパティに設定します。

3. テストデプロイの実施

  • Google Apps Scriptのコンソールで、コードのテスト実行を行い、正しく動作するか確認します。
  • 「公開」メニューから「デプロイ」を選択し、新しいテストデプロイを作成します。
  • 生成されたテストURLを使用して、アプリケーションの動作をブラウザで確認します。
    5

参考

下記を参考にさせていただいております。
https://zenn.dev/greek_academy/articles/1c5be03365e92a

https://dev.classmethod.jp/articles/node-gemini-api/

https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini?hl=ja

GitHubで編集を提案

Discussion