【Google Apps Script】Google Meet APIで会議参加者を取得してみた – システム構築 後編 –

技術

いよいよ完結編

前々回Google Meet APIを使うための準備をしました。

そして、前回はGoogle Meet APIでGoogle Meetの会議スペースを必要数分作成するプログラムをGASで実装しました。

そんなわけで、いよいよ今回はもくもく会システムを 完成 させたいと思います。

前回考えたもくもく会システムとして必要になる機能はこの通りでした。

  1. アクセストークンの有効期限切れたらリフレッシュトークンでアクセストークンを更新する機能
  2. 会議スペースを必要分だけ作成する機能
  3. それぞれの会議スペースでオンライン中の参加者をリストアップする機能
  4. 作成した会議スペースとそれぞれの会議スペースの参加者をGoogle Spread Sheetへ反映する機能

このうち、1 と 2 は前回作ったのであとは 3 と 4 を実装すれば完成させられそうです。

ということで早速「3. オンライン中の参加者をリストアップする機能」を作りたいところですが、先に「4. 作成した会議スペースと会議スペースの参加者をGoogle Spread Sheetへ反映する機能」を作ることにします。

Google Spread Sheetへ反映する機能

一言で「Google Spread Sheetへ反映する機能」といっても、いくつか必要になる機能があります。
列挙してみるとこんな感じです。

  1. 新しいSpread Sheetを作成して、GASで作成された会議スペースの情報を書き込む
  2. Spread Sheetから会議スペースの情報を読み取る
  3. 会議スペースの参加者をSpread Sheetへ書き込む

では順番に作っていきましょう。

createNewSpreadSheet

function createNewSpreadSheet(name, rooms) {
  // SoreadSheetを新規作成して、ドメイン内ユーザーに閲覧権限をセット
  const spreadSheet = SpreadsheetApp.create(name);
  const id = spreadSheet.getId();
  const file = DriveApp.getFileById(id);
  file.setSharing(DriveApp.Access.DOMAIN_WITH_LINK, DriveApp.Permission.VIEW);
  // 会議スペースへの参加者を表示するエリアのデフォルトの表示件数を10人とする
  const maxUserCount = 10;
  // シートを取得、シート名の設定
  const sheet = spreadSheet.getActiveSheet();
  sheet.setName(name);
  // A列(項目名の列)を設定
  sheet.setColumnWidth(1, 100);
  sheet.getRange(1, 1, 6, 1).setFontWeight('bold')
    .setHorizontalAlignment('center')
    .setVerticalAlignment('middle')
    .setValues([['ルーム'],['ルーム概要'],['ルームコード'],['ルームリンク'],['会議コード'],['現在の参加者']]);
  sheet.getRange(1,1, 5, 1).setBackground('#d9ead3');
  sheet.getRange(6, 1, maxUserCount, 1).setBackground('#cfe2f3');
  // B列以降(各会議スペースの情報を表示する列)を設定
  for (let index in rooms) {
    const column = parseInt(index) + 2;
    const room = rooms[index];
    sheet.setColumnWidth(column, 270);
    // ルーム名の表示
    sheet.getRange(1, column).setValue(room.name)
      .setFontWeight('bold').setFontSize(14)
      .setHorizontalAlignment('center');
    // ルーム説明の表示
    sheet.getRange(2, column).setValue(room.description)
      .setWrap(true).setVerticalAlignment('top');
    // ルームコード、URL、会議コードの表示
    sheet.getRange(3, column, 3, 1)
      .setValues([[room.meetingCode],[room.meetingUri],[room.conferenceCode]]);
    // ルーム参加者の表示部分を中央寄せ表示
    sheet.getRange(6, column, maxUserCount, 1)
      .setHorizontalAlignment('center')
      .setVerticalAlignment('middle');
  }
  // 会議コードの行は非表示
  sheet.hideRow(sheet.getRange('A5'));
  // 罫線を引く
  sheet.getRange(1, 1, 5 + maxUserCount, rooms.length + 1)
    .setBorder(null, true, null, true, true, null, null, SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
  sheet.getRange(6, 1, 1, rooms.length + 1)
    .setBorder(true, null, null, null, null, true, null, SpreadsheetApp.BorderStyle.DOUBLE);

  return spreadSheet;
}

createNewSpreadSheetは「新しいSpread Sheetを作成して、GASで作成された会議スペースの情報を書き込む」関数です。

細かな説明は省略しますがいくつかポイントがあります。

  1. SpreadsheetApp.create(name)でGoogle Driveのマイドライブ直下にスプレッドシートが生成されます。(3行目)
  2. 生成したスプレッドシートにはDriveApp.File.setSharing()で共有設定を付けています。
    共有は「ドメイン内のリンクを知っているメンバーに閲覧権限を与える」設定にしました。(6行目)
  3. スプレッドシートの5行目は「会議コード」を表示する部分ですが、見せる必要がないので非表示にしています。

この createNewSpreadSheet を例えば引数をこんな感じで指定して実行してみます。

createNewSpreadSheet(
  "もくもく会【2024-06-15】",
  [
    {
      "name": "<<メインセッション>>",
      "description": "もくもく会参加者の方は、最初にこちらのルームへお入りください。",
      "meetingCode": "xxx-xxxx-xx1",
      "meetingUri": "https://meet.google.com/xxx-xxxx-xx1"
    },
    {
      "name": "◎自習室",
      "description": "基本的にマイクをミュートにしてください。",
      "meetingCode": "xxx-xxxx-xx2",
      "meetingUri": "https://meet.google.com/xxx-xxxx-xx2"
    },
    {
      "name": "☆会議室1",
      "description": "自由に出入りして、会話してください。",
      "meetingCode": "xxx-xxxx-xx3",
      "meetingUri": "https://meet.google.com/xxx-xxxx-xx3"
    },
    {
      "name": "☆会議室2",
      "description": "自由に出入りして、会話してください。",
      "meetingCode": "xxx-xxxx-xx4",
      "meetingUri": "https://meet.google.com/xxx-xxxx-xx4"
    },
    {
      "name": "☆会議室3",
      "description": "自由に出入りして、会話してください。",
      "meetingCode": "xxx-xxxx-xx5",
      "meetingUri": "https://meet.google.com/xxx-xxxx-xx5"
    }
  ]);

するとこんな感じのスプレッドシートが作られるはずです。

多少見やすくなるように最低限のレイアウト調整だけしている感じです。
自分の気に入るように好きに変更してみてください。

getRoomData

次は「Spread Sheetから会議スペースの情報を読み取る」部分です。

function getRoomData(spreadSheetId) {
  // スプレッドシートを開く
  const spreadSheet = SpreadsheetApp.openById(spreadSheetId);
  const name = spreadSheet.getName();
  const sheet = spreadSheet.getSheetByName(name);
  // A列は項目名なのでスキップ。B列から検索
  const roomData = [];
  let col = 2;
  while (!sheet.getRange(1, col).isBlank()) { // ルーム名が取れなくなるまで繰り返す
    // 1列ずつ情報を取得
    const values = sheet.getSheetValues(1, col, 5, 1);
    roomData.push({
      name: values[0][0],
      description: values[1][0],
      meetingCode: values[2][0],
      meetingUri: values[3][0],
      conferenceCode: values[4][0]
    });
    col++;
  }

  return roomData;
}

createNewSpreadSheetで作ったシートから情報を読み取ってオブジェクトにして返しているだけです。

当然、サンプルで示したcreateNewSpreadSheetから内容を変更してレイアウトを変えた場合はここも合わせて修正する必要がありますよ。

updateParticipants

最後に「会議スペースの参加者をSpread Sheetへ書き込む」部分を作ります。

function updateParticipants(spreadSheetId, conferences) {
  // スプレッドシートを開く
  const spreadSheet = SpreadsheetApp.openById(spreadSheetId);
  const name = spreadSheet.getName();
  const sheet = spreadSheet.getSheetByName(name);

  // 2次元配列の縦横を入れ替える関数を定義
  const transpose = a => a[0].map((_, c) => a.map(r => r[c]));

  // A列は項目名なのでスキップ。B列から検索
  let col = 2;
  while(!sheet.getRange(1, col).isBlank()) { // ルーム名が取れなくなるまで繰り返す
    const roomCode = sheet.getRange(3, col).getValue();
    const conference = conferences[roomCode];
    if (conference != undefined) {
      // 既存の参加者表示を一旦クリア
      const lastRow = sheet.getRange(sheet.getMaxRows(), col).getNextDataCell(SpreadsheetApp.Direction.UP).getRowIndex() + 1;
      if (lastRow > 6)
            sheet.getRange(6, col, lastRow - 6, col).clearContent();

      if (conference.participants.length > 0) {
        // 参加者を再表示
        sheet.getRange(5, col).setValue(conference.conferenceCode);
        sheet.getRange(6, col, conference.participants.length, 1).setValues(transpose([conference.participants]));
      } else {
        // 参加者がいない場合、会議コードはクリア
        sheet.getRange(5, col).clearContent();
      }
    }
    col++;
  }
}

サンプルでは5行目に会議コード、6~16行目に会議の参加者を表示するレイアウトになっているので、そのレイアウトに合わせて会議コードと参加者のリストを表示するように実装しています。

もちろんこちらもcreateNewSpreadSheetで作成するレイアウトに合わせるように作る必要がありますね。

オンライン会議参加者をリストアップする機能

いよいよ佳境です。
次はGoogle Meet APIでオンライン会議の参加者を取得する部分を作っていきましょう。

前回も少しだけ触れましたが、オンライン会議の参加者は会議スペース単位ではなく会議レコード単位で管理されています。

なので、参加者を取得する流れはこうなります。

  1. まず会議スペース内で現在開催されれている会議レコードを特定する。
  2. それから会議レコード内の参加者を取得する。

会議レコードを取得する

会議スペースで現在開催されている会議レコードを取得するのは簡単です。

URL「https://meet.googleapis.com/v2/spaces/{meeting_code}」に対してGETリクエストを投げるだけ。
もちろんリクエストヘッダのAuthorizationにアクセストークンをセットしておきます。
※{meeting_code}にはSpreadSheetで「ルームコード」として出力した値を指定します。

function getConferenceCode(meeting_code) {
  const access_token = getAccessToken();
  const url = `https://meet.googleapis.com/v2/spaces/${meeting_code}`;
  const option = {
    'headers': {
      'Authorization': 'Bearer ' + access_token,
      'Content-Type': 'application/json'
    },
    'muteHttpExceptions': true,
    'method': 'GET'
  };
  const response = UrlFetchApp.fetch(url, option);
  const responseContent = JSON.parse(response.getContentText());
  if (responseContent.activeConference) {
    return responseContent.activeConference.conferenceRecord;
  } else {
    return undefined;
  }
}

GETリクエストが成功すればレスポンスにはこんな感じのオブジェクトが返ってきます。

{
  name: 'spaces/9xXxxxXXx9XX',
  meetingUri: 'https://meet.google.com/xxx-xxx-xx1',
  meetingCode: 'xxx-xxx-xx1',
  config: { accessType: 'TRUSTED', entryPointAccess: 'ALL' },
  activeConference: { conferenceRecord: 'conferenceRecords/99999999-9999-9999-9999-999999999999' }
}

activeConference.conferenceRecordとして指定されている値が「会議レコード」を表す会議コードということになります。

もし、会議がまだ開催されていない場合、activeConferenceはundefinedということになります。

会議レコード内の参加者を取得

最後に参加者の取得方法です。

URL「https://meet.googleapis.com/v2/{conference_code}/participants」に対してGETリクエストを投げるだけ。
※{conference_code}には先ほど取得した会議コードを指定します。

function getParticipants(conference_code) {
  const access_token = getAccessToken();
  const url = `https://meet.googleapis.com/v2/${conference_code}/participants`;
  const option = {
    'headers': {
      'Authorization': 'Bearer ' + access_token,
      'Content-Type': 'application/json'
    },
    'muteHttpExceptions': true,
    'method': 'GET'
  };
  const response = UrlFetchApp.fetch(url, option);
  const conference = JSON.parse(response.getContentText());
  // 会議情報から参加者情報を抜き出す
  const participants = [];
  if (conference.participants != undefined) {
    for (let participant of conference.participants) {
      if (participant.latestEndTime != undefined) {
        // 退出済みなのでスキップ
        continue;
      }
      participants.push(participant.signedinUser.displayName);
    }
  }
  return participants;
}

GETリクエストはこんな感じのJSONが返ってきます。

{
  participants: [
    {
      name: 'conferenceRecords/xxxxxx-xxxx-xx-xxxx-xxx/participants/99999999',
      signedinUser: { user: 'users/99999999', displayName: 'ゲストA' },
      earliestStartTime: '2024-06-15T00:43:34.258623Z',
      latestEndTime: '2024-06-15T02:19:58.556819Z'
    },
    {
      name: 'conferenceRecords/xxxxxx-xxxx-xx-xxxx-xxx/participants/88888888',
      signedinUser: { user: 'users/88888888', displayName: 'ゲストB' },
      earliestStartTime: '2024-06-15T01:02:20.145634Z'
    }
  ]
}

参加者が取得できているのがわかります。
注意が必要なのは取得した参加者の中には、すでに会議を退出した人も含まれていることです。
その場合、退出した人にはlatestEndTimeというプロパティに退出時間が記録されているので判別が可能です。

最終的にgetParticipants()では会議に残っている参加者の表示名(displayName)を配列で返す仕様にしました。

システム構築

とうとう必要な機能が出そろいました。
あとはここまで作ってきた機能を組み合わせてシステムを構築するだけです。

システムとして必要になるものは下記の4つです。

  1. スケジュール機能
    ・もくもく会の開始、終了を予約する
    ・会議スペースを作成してSpreadSheetに反映する
  2. もくもく会の開始機能
    ・1分間隔で参加者を更新するトリガーを作成する
  3. もくもく会の終了機能
    ・全てのトリガーを破棄する
  4. 参加者を更新する機能
    (もくもく会の開始~終了まで1分間隔で実行される)
    ・会議の参加者を取得
    ・取得した参加者をSpreadSheetに反映する

一気に実装してしまいましょう。

function scheduleMokuMokuMeeting() {
  const begin = Utilities.parseDate('2024-06-19 05:55:00', 'JST', "yyyy-MM-dd' 'HH:mm:ss");
  const end = Utilities.parseDate('2024-06-19 06:00:00', 'JST', "yyyy-MM-dd' 'HH:mm:ss");
  const title = `もくもく会【${Utilities.formatDate(begin, 'JST', 'yyyy-MM-dd')}】`;
  // もくもく会の開始と終了を予約する
  ScriptApp.newTrigger('startMokuMokuMeeting')
    .timeBased()
    .at(begin)
    .create();
  ScriptApp.newTrigger('finishMokumokuMeeting')
    .timeBased()
    .at(end)
    .create();
  // もくもく会用の会議室を作成する
  const rooms = JSON.parse(PropertiesService.getScriptProperties().getProperty("rooms"));
  const spaces = createMeetingSpaces(rooms.length);
  // 作成した会議室をSpreadSheetに出力
  rooms.forEach((room, index) => {
    room.meetingCode = spaces[index].meetingCode;
    room.meetingUri = spaces[index].meetingUri;
  });
  const spreadSheet = createNewSpreadSheet(title, rooms);
  // SpreadSheetのIDをユーザープロパティに記録しておく
  PropertiesService.getUserProperties().setProperty("MokuMokuMeeting.SpreadSheet", spreadSheet.getId());
  // スプレッドシートのURLをログ出力
  console.log(spreadSheet.getUrl());
}

function startMokuMokuMeeting() {
  // 1分間隔で参加者表示を更新するトリガーを設定
  ScriptApp.newTrigger('refreshParticipants')
    .timeBased()
    .everyMinutes(1)
    .create();

  // 最初のrefreshParticipantsは1分後なので
  // 1回目はここで実行しておく
  refreshParticipants();
}

function finishMokumokuMeeting() {
  // すべてのトリガーを削除
  const triggers = ScriptApp.getProjectTriggers();
  for (let trigger of triggers) {
    ScriptApp.deleteTrigger(trigger);
  }
  // ユーザープロパティに記録してあるSpreadSheetのIDを削除
  PropertiesService.getUserProperties().deleteProperty("MokuMokuMeeting.SpreadSheet");
}

function refreshParticipants() {
  // もくもく会用の会議スペース情報を取得
  const spreadSheetId = PropertiesService.getUserProperties().getProperty("MokuMokuMeeting.SpreadSheet");
  const rooms = getRoomData(spreadSheetId);
  // 参加者を取得
  const conferences = {};
  for (let room of rooms) {
    let participants = [];
    // 会議コードを取得済みならその会議コードで参加者を取得
    if (room.conferenceCode) {
      participants = getParticipants(room.conferenceCode);
    }
    // 会議コードを未取得、またはその会議レコードから参加者が全員退出済みなら
    // 会議スペース内の新たな会議コードを取得
    if (participants.length == 0) {
      room.conferenceCode = getConferenceCode(room.meetingCode);
      // 会議コードが取得できたら、参加者を取得
      if (room.conferenceCode) {
        participants = getParticipants(room.conferenceCode);
      }
    }
    conferences[room.meetingCode] = {
      conferenceCode: room.conferenceCode,
      participants: participants
    }
  }
  // 参加者をSpreadSheetへ反映
  updateParticipants(spreadSheetId, conferences);
}

scheduleMokuMokuMeeting()を実行するとGoogle Driveの「マイドライブ」直下にもくもく会用のスプレッドシートが作成されます。

あとは開始時間になれば勝手にオンライン会議参加者の表示が1分間隔で更新されるようになって、終了時間になれば表示更新が止まるようになっています。

注意しないといけないのは

  • scheduleMokuMokuMeeting()を実行する時にもくもく会の開始時間、終了時間を書き換えておかないといけないこと。
  • 複数のもくもく会を開催することは考慮していないこと。

複数のもくもく会は開催できないので、すでに予約済みなら一度finishMokumokuMeeting()してから予約するか、予約済みの場合は次の予約はできないように制御する仕組みもあった方がいいかもしれません。

まとめ

全3回に渡って作ってきたGoogle Meet APIを使った「オンラインもくもく会システム」がようやく完成しました。

もくもく会自体は色々なやり方があると思いますが、私個人としてはいくつかのルームを用意して自由に行き来できるスタイルが気に入っています。

皆さんはどんなやり方が好みですか?
自分に都合のいいやり方に合わせて、システムを組んでみるのも楽しいかもしれません。

ぜひやってみてください!


もくもく会システム開発 シリーズ目次

  1. 【Google Apps Script】Google Meet APIで会議参加者を取得してみた – 準備編 –
  2. 【Google Apps Script】Google Meet APIで会議参加者を取得してみた – システム構築 前編 –
  3. 【Google Apps Script】Google Meet APIで会議参加者を取得してみた – システム構築 後編 –

コメント

タイトルとURLをコピーしました