Google Apps ScriptでGoogle Groupsのメンバーを更新する

※本記事の内容は2021年12月26日のものです．

# 目標

Google Groupsのメンバーの追加や削除を，ブラウザのUIを経由せずに，Google Apps Script (GAS)を用いて行う．

# 環境

Google Workspace for Nonprofits

# 準備

Workspaceの管理者として: https://admin.google.com/ のセキュリティ > APIの制御 > ドメインで所有する内部アプリを信頼する にチェックを入れる．(※)

Drive SDK API access を有効にする．サイドメニュー > アプリ > Google Workspace > ドライブとドキュメント > 機能とアプリケーション > Drive SDK (※)

上記2つの(※)は多分必要と思われますが，検証していません．

Google Driveの適当なフォルダにスプレッドシートを作成する．スクリプト実行の土台として使用する．また作成したスプレッドシートに記入された情報に基づいてメンバーの管理を行う．

スプレッドシートの 拡張機能 > Apps Script を選択してスクリプトエディタを起動する．

左側のサービスの横の+をクリックすると，サービスを追加するダイアログが開く．Admin Directory APIを追加する．

# 機能ごとの動作確認

Google Groupsに対する操作方法と，Google Sheetsに対する操作方法の，両方を確認する．

## GAS一般

### ログの出力

各種の実行ログの出力は`console.log()`で行える．

## Sheets

### 全シート名の取得

スプレッドシート内にある全てのシート名を得る．

```js:
function GetAllSheetNames() {
  const sheets = SpreadsheetApp.getActiveSpreadsheet().getSheets();

  for (let sheet of sheets) {
    console.log(sheet.getName())
  }
}
```

### 全シート名を取得し，シートに出力

全シート名を，あるシート(ここではシート名print)に出力する．

```js:
function OutputAllSheetNames() {
  const sheets = SpreadsheetApp.getActiveSpreadsheet().getSheets();

  let values = [];
  for (let sheet of sheets) {
    let rowData = [];
    rowData.push(sheet.getName());
    values.push(rowData);
  }
  
  const sheetPrint = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('print');
  sheetPrint.clear();
  sheetPrint.getRange(1, 1, values.length, 1).setValues(values);  
}
```

### 全データの取得

あるシート(ここではシート名new)に書かれている全データを取得する．

```js:
function GetAllData() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('new');
  const values = sheet.getDataRange().getValues(); // getDataRange()でデータの存在範囲が得られる．
  const data = values.flat().sort(); // 配列の配列を1次元にし，ソートする．

  console.log(data);
}
```

`getValues()`で得られる`values`は配列の配列である．
このサンプルではメソッド`flat()`によって1次元化した後，メソッド`sort()`によってソートしているが，これは筆者の都合である(シートの1列目にメンバーのメールアドレスが記載されていると想定しているため)．

参考: [【GAS・スプレッドシート】範囲・セルの値を取得する方法｜選択中や指定した範囲の値](https://blog-and-destroy.com/33780)

## Groups

### 全グループ情報の取得

あるドメイン(ここではexample.org)に属する全てのグループの情報を得る．

```js:
const domainName = 'example.org'; // 環境に合わせて変更

function GetAllGroupNames() {
  const groupsList = AdminDirectory.Groups.list({
    domain: domainName,
    maxResults: 200
  });

  for (let group of groupsList.groups) {
    console.log(group);
  }
}
```

### メンバー一覧の取得(200名以下)

あるグループ(ここではtest@example.org)のメンバー一覧を得る(200名以下の場合)．

```js:
function GetGroupMember()
{
  const groupEmail = 'test@example.org';
  const members = AdminDirectory.Members.list(groupEmail).members;

  console.log(members);
}
```
メンバー一覧を得るだけなら，グループそのものを直接扱わなくてもできる．

### メンバー一覧の取得(200名超)

同時に取得できる人数が最大200名という制約があるため，200名を超える場合には複数回実行して結果を統合する必要がある．

```js:
function GetOver200Members() {
  const groupEmail = 'test@example.org';
  let pageToken;
  let page;
  let members = [];
  do {
    page = AdminDirectory.Members.list(groupEmail, {
      maxResults: 200,
      pageToken: pageToken
    });
    members = [...members, ...page.members];
    pageToken = page.nextPageToken;
  } while (pageToken);

  console.log(members);
}
```

[Admin SDK Directory Service](https://developers.google.com/apps-script/advanced/admin-sdk-directory) のList all usersやList all groupsのソースコードを参考にし，対象をメンバーとするように改変した．`nextPageToken`がある間は続きがあるため実行を繰り返す．

結果の統合にはスプレッド演算子(`...`)を使用した．

参考: [JSのスプレッド構文を理解する](https://qiita.com/akisx/items/682a4283c13fe336c547)

### メンバーの追加

あるグループにメンバー(メールアドレス: test-user@example.com)を追加する．

```js:
function AddMember()
{
  const groupEmail = 'test@example.org';
  const newMember = {
    email: 'test-user@example.com',
    role: 'MEMBER'
  };
  AdminDirectory.Members.insert(newMember, groupEmail); // 既に登録されていたら fatal error でストップする．
}
```

試した限りでは`insert()`の第1引数に直接メールアドレスを指定することはできない．

### メンバーの削除

あるグループからメンバー(メールアドレス: test-user@example.com)を削除する．

```js:
function DeleteMember()
{
  const groupEmail = 'test@example.org';
  const memberEmail = 'test-user@example.com';
  AdminDirectory.Members.remove(groupEmail, memberEmail); // 存在していなければ fatal error でストップする．
}
```

削除の場合は追加と異なり`remove()`の第2引数には削除したいメンバーのメールアドレスを直接指定する．

# 実際に作成したスクリプトの例

(1)グループtest@example.orgのメンバーを，(2)シートnewに記載されているメールアドレスに更新するスクリプトを作成した．具体的には以下に示す操作を行う．

- (1)と(2)の両方に記載がある場合は，そのまま何もしない．
- (1)のみに記載がある場合は，削除する．
- (2)のみに記載がある場合は，新規登録する．

実行方法: スクリプトエディタで実行する関数として`UpdateMemberList`を選択し，実行をクリックする．

各メールアドレスに対する操作をシートactionに出力するようになっているため，あらかじめシートactionを用意しておく．シートactionの内容は実行のたびにクリアされる．

```js:
// 対象グループのメールアドレス
const TARGET_GROUP = 'test@example.org';

// メンバーのメールアドレス一覧が登録されているシート名
const NEW_EMAIL_SHEET = 'new';

// 各メールアドレスに対する操作を記録するシート名
const ACTION_SHEET = 'action';

// 追加のみ実行するか
const IS_ADD_ONLY = true; // false だと追加と削除を実行

// 実際に実行するか
const IS_EXEC = false; // true だと実行

// グループ名(メールアドレス)を指定すると，登録されているメンバーを返す．
// 200名を超えていてもOK

function GetMembers(group) {
  let pageToken;
  let page;
  let members = [];
  do {
    page = AdminDirectory.Members.list(group, {
      maxResults: 200,
      pageToken: pageToken
    });
    members = [...members, ...page.members]; // ... はスプレッド演算子で配列の内容を展開する
    pageToken = page.nextPageToken; // 次のページトークンを得る
  } while (pageToken);

  return members;
}

// 以下が実行するべき関数

function UpdateMemberList() {
  // 現在グループに登録されているメンバの情報
  const members = GetMembers(TARGET_GROUP);

  // シート new に記載されている登録者リスト
  const sheetNew = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(NEW_EMAIL_SHEET);
  // 配列の配列は扱いにくいので，flat()で1次元にする．
  const newEmailsUnchecked = sheetNew.getDataRange().getValues().flat();

  // 新しい登録者には，重複がある可能性があるため，確認する．
  let newEmails = [];
  for (let email of newEmailsUnchecked) {
    if (newEmails.includes(email)) { // 既にある
      console.log('duplicated:', email);
      continue;
    }
    newEmails.push(email);
  }

  // 相互にメールアドレスのチェック

  // 現在のメンバー members について for をまわす．
  // 何もしないリスト keepEmails
  // 削除するリスト deleteEmails
  // 両方を合わせたリスト registeredEmails
  let keepEmails = [];
  let deleteEmails = [];
  let registeredEmails = [];
  for (let member of members) {
    const email = member.email;
    if (newEmails.includes(email)) { // 配列のメソッド includes() で存在チェック
      keepEmails.push(email);
    } else {
      deleteEmails.push(email);
    }
    // チェック用にメールアドレスのリストも作る
    registeredEmails.push(email);
  }

  // 次に，newEmails について for をまわす．registeredEmails に入っているかチェックする．
  // 何もしないリスト keepEmailsNew
  // 登録するリスト addEmails
  let keepEmailsNew = [];
  let addEmails = [];
  for (let email of newEmails) {
    if (registeredEmails.includes(email)) {
      keepEmailsNew.push(email);
    } else {
      addEmails.push(email);
    }
  }

  // 整合性を確認できるように，結果の個数をconsoleに表示する．
  console.log('KEEP:', keepEmails.length);
  console.log('DELETE:', deleteEmails.length);
  console.log('ALL:', registeredEmails.length);
  console.log('KEEP(NEW):', keepEmailsNew.length);
  console.log('ADD:', addEmails.length);
  console.log('ALL(NEW):', newEmails.length);

  // チェックと，実行内容がみやすいように，ソートしておく．
  keepEmails.sort();
  keepEmailsNew.sort();
  deleteEmails.sort();
  addEmails.sort();

  // キープするメールアドレスは同じになっているはずなので，チェックする．
  if (keepEmails.length != keepEmailsNew.length) {
    console.log('keepEmails.length != keepEmailsNew.length');
  }
  for (let i = 0; i < keepEmails.length; i++) {
    if (keepEmails[i] != keepEmailsNew[i]) {
      console.log('keepEmails != keepEmailsNew', keepEmails[i], keepEmailsNew[i]);
    }
  }

  // 各メールアドレスに対する処理内容をシート action に出力する．
  let values = []; // 出力用空配列
  values.push(['メールアドレス', '操作']);
  for (let email of keepEmails) {
    let rowData = [];
    rowData.push(email);
    rowData.push('KEEP');
    values.push(rowData);
  }
  for (let email of deleteEmails) {
    let rowData = [];
    rowData.push(email);
    rowData.push('DELETE');
    values.push(rowData);
  }
  for (let email of addEmails) {
    let rowData = [];
    rowData.push(email);
    rowData.push('ADD');
    values.push(rowData);
  }
  const sheetAction = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(ACTION_SHEET);
  sheetAction.clear();
  sheetAction.getRange(1, 1, values.length, 2).setValues(values);

  // いざ，操作を実行する!!!!!
  if (IS_EXEC) {
    // deleteEMails のエントリを削除する
    if (!IS_ADD_ONLY) {
      for (let email of deleteEmails) {
        AdminDirectory.Members.remove(TARGET_GROUP, email);
      }
    }
    // addEMails のエントリを追加する
    for (let email of addEmails) {
      let member = {
        email: email,
        role: 'MEMBER'
      };
      AdminDirectory.Members.insert(member, TARGET_GROUP);
    }
  }
}
```

# 参考

- [Google Apps Script](https://developers.google.com/apps-script/)
- [Admin SDK: Directory API](https://developers.google.com/admin-sdk/directory/reference/rest)
- [Admin SDK Directory Service](https://developers.google.com/apps-script/advanced/admin-sdk-directory)
- [JavaScript リファレンス](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference)
- [【G Suite】Googleスプレッドシートでグループ一覧を取得する](https://dev.classmethod.jp/articles/g-suite-get-group-list/)
- [【G Suite】APIでグループ作成・メンバー追加・アクセスタイプ設定をやってみる](https://dev.classmethod.jp/articles/g-suite-groups-insert/)