Google Workspaceで社員連絡先を社員全員に同期する方法【GAS】

自分が情シスとして仕事をしていた時代、iPhoneを全員に業務用スマホとして配布していたのですが、社員連絡先の同期が非常に厄介でした。CSVで掲示板に定期的に配布し、個々人手動でGoogle Contactsに入れ直すという作業が必要でした。

そこで前回はボタンクリックは必要だけど、この入れ直す作業を自動化しています。今回はさらに一歩進んでスプシ上のデータを全員のGoogle Contactsに管理者側からプッシュして最新一覧とする方法です。

今回利用する素材

Google Contactsには、連絡先にディレクトリという項目があり、これはGoogle Workspaceの社員情報が表示されています。しかし、iPhoneの連絡帳としてはこれが使えない。しかし、通常の方法では社員個々人の連絡先は管理者からは操作できない。故にCSVだの手動で入れ替えだのが必要でした。

アカウントの共有はポリシー違反となるのでその手法もNG。また連絡先の委任を使っての手法も非常に手間の掛かる手法です。GoogleのCardDAVを使って出来ないかとも思ったのですが、これも基本個々人単位の管理になるため使えない。

となるとユーザーにアクセスさせるのではなく、こちらからユーザーの連絡先に対してプッシュする手法が必要になります。

※実はUIは無いものの、隠し機能として残り続けてる共有外部連絡先というのがGoogle Workspaceに存在してたりします。ただこちらはiOSの連絡先として使えるか?は不明。

Google Apps ScriptでContactsをPeople APIで弄る【GAS】

Google Apps Scriptで共有の外部連絡先を管理する【GAS】

事前準備

Google Cloud側の作業

APIの有効化

Google Cloud Console側でAPIを有効化する必要性があります。

  1. GCPのプロジェクトを開く
  2. 左サイドバーからAPIとサービスにて、「APIとサービスの有効化」をクリックする
  3. peopleと検索すると出てくるので、クリックします。
  4. 有効化をクリックします。
  5. 認証情報の作成は不要です
  6. Drive APIもついでにオンにしておきましょう。

図:有効化をしておくだけでOK

サービスアカウントの作成

作成上の注意点

2024年より、新規のGoogle Cloudテナント作成時におけるデフォルトの組織ポリシー変更が発生しており、検証環境を作ってもらったはいいけれど、いざサービスアカウントを作成しようとすると作成権限が無いとして作れないといったケースが発生しています。

対象になるポリシーは「Disable service account key creation」であり、デフォルトで有効化されてしまっています。テナント作成担当者に伝えて、このポリシーをfalseにしてもらう必要があります。

他にも別のドメインのユーザをテナントのIAMに追加するものもできなくなっているので、他のドメインユーザをプロジェクトに参画させる場合には解除が必要です。

作成手順

GASで利用するサービスアカウントを用意する必要があります。このアカウントは1本でオッケー。以下の手順でGCP上でサービスアカウントを作成します。

  1. GCP画面の左サイドバーより、IAMと管理⇒サービスアカウントを開く

  2. 上部にあるサービスアカウントの作成をクリックする

  3. 適当なサービスアカウント名、説明文を入れて作成して続行をクリックする

  4. このサービス アカウントにプロジェクトへのアクセスを許可するでは、ロールは付与不要です。

  5. ほかは省略するので、完了をクリックして終わらせる

  6. 一覧に作ったサービスアカウントが出てくるので、アカウント名をクリックする

  7. 詳細が開かれるのでこの時表示されてる一意の ID」をメモしておく

  8. 上部タブの「キー」をクリックして、鍵を追加⇒新しい鍵を作成をクリックする

  9. キーのタイプはJSONを選び、作成をクリックする

  10. JSONファイルがダウンロードされるので、Google Driveの安全な場所にアップロードする

  11. アップロードした10.のファイルのIDを取得する

作成したサービスアカウントおよびJSONキーは後ほど利用することになります。

図:サービスアカウントを作成中

図:一意のIDが重要です

GAS側の作業

プロジェクトの連結

Google Apps ScriptとCloud Consoleのプロジェクトを紐付けする作業が必要です。以下の手順でプロジェクトの変更を行います。

  1. Cloud Console側のプロジェクトのホームを開き、対象プロジェクトに切り替えてから「プロジェクト番号」を控えておく
  2. Google Apps Scriptのスクリプトエディタを開く
  3. サイドバーからプロジェクト設定を開く
  4. GCPプロジェクトの「プロジェクトを変更」をクリック
  5. GCPのプロジェクト番号に、1.の番号を入力して、プロジェクトを設定をクリック
  6. これで紐付けが完了しました。

図:プロジェクトの移動も必須の作業です

ライブラリの追加

以下の手順でOAuth2 for Apps Scriptライブラリを追加しましょう。

  1. スクリプトエディタを開きます。
  2. メニューより「リソース」⇒「ライブラリ」を開きます。
  3. ライブラリを追加欄に「1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF」を追加します。
  4. 今回はバージョンは43を選択してみます。
  5. 保存ボタンを押して完了

図:ライブラリを追加した様子

appsscript.jsonに記述を追加する

スクリプトエディタの左サイドバーから「プロジェクト設定」を開き、「appsscript.json」マニフェスト ファイルをエディタで表示するにチェックを入れて、appsscript.jsonを表示する。その後そのファイルを開き、以下のように記述を行います。必須の作業です。

"oauthScopes": [
  "https://www.googleapis.com/auth/contacts",
  "https://www.googleapis.com/auth/spreadsheets",
  "https://www.googleapis.com/auth/userinfo.profile",
  "https://www.googleapis.com/auth/drive",
  "https://www.googleapis.com/auth/script.external_request"
]

People APIで使うScopeを上記のように指定する必要があります。これを指定しておかないと403エラー Permission Denied等のエラーが発生してしまいます。

初回認証をしておく

ここまでの準備で適当な関数を用意して実行すると認証が始まります。連絡先の表示、編集、ダウンロード、完全な削除というスコープが出てくるはずです。

図:認証を実行するとこの画面が出る

ドメイン全体の委任

Google Workspaceの管理コンソール側でも作業が必要です。Google Cloud側で有効化したAPIについてドメインの委任をする必要があります。

  1. 管理コンソールを開く

  2. 左サイドバーからセキュリティ⇒アクセスとデータ管理⇒APIの制御を開く

  3. ドメイン全体の委任にある「ドメイン全体の委任の管理」をクリックする

  4. 新しく追加をクリックする

  5. 前述の項目でメモっておいた「一意のID」をクライアントIDに入力する(これはサービスアカウントの一意のIDであって、OAuthのクライアントIDではないので注意)

  6. OAuthスコープには以下の項目を追加しておく

    https://www.googleapis.com/auth/contacts
  7. 承認をクリックする

  8. 詳細を表示から中身は後で確認や書換が可能です。

なお、ドメイン全体の委任については危険性も指摘されているので、厳重に管理する必要があります。

図:ドメイン全体の委任に追加中の様子

スプレッドシートの整備

スプレッドシートは単純です。2つのシートが存在し、ルールも明快です。

userlistシート

相手先の連絡先に押し込むための連絡先データを入れてる場所です。このシートには2つ注意点があります。

  • UID列:社員番号などの不変のユニークな値を入れておきます。
  • note列 : 処理途中でテンポラリで使う列です。作業したデータかどうかを判定する為に使ってる為、値は入れないでください
  • phone列:列全体の書式を「書式無しテキスト」にしておいてください。09012345678というハイフン無しで登録
  • fullName列:半角スペースで区切って姓名を入れます

図:簡単な連絡先情報を管理してる

targetシート

userlistのデータを押し込む先のユーザーのメールアドレスを列挙しておく為のシートです。このシートに列挙されてるユーザーの連絡先をスクリプトから更新を掛けます。

図:プッシュ先ユーザーのリスト

ソースコード

グローバル設定

スクリプト全体で利用するグローバル変数の設定です。ここでは、読み書きするスプシのIDと、前述でアップロードしたJSONのキーファイルのIDをここに記述しておきます。

//読み書き先スプシのID
const ssid = "読み書きするスプシのIDを入れる";

//スクリプト管理用識別子(メモ欄に値が入ります)
const SCRIPT_MANAGED_TAG = 'ManagedByGAS-ContactSync-v1';

//JSONキーファイル
const keyfile = "アップロードしたJSONキーファイルのID";

//People API エンドポイント
const endpoint = "https://people.googleapis.com/v1/"

認証関係のコード

OAuth2.0ライブラリでGoogle Cloud側のサービスアカウントの認証を行いますが、ユーザー権限で実行はしない為、手動で実行する必要はありません。アクセストークンはスクリプトプロパティに格納されます。

//サービスアカウント認証用
function getOAuthService() {
  const serviceAccountCredentials = getServiceAccountCredentials();
  const credentials = JSON.parse(serviceAccountCredentials);
  return OAuth2.createService('PeopleAPI_ServiceAccount')
    .setTokenUrl('https://oauth2.googleapis.com/token')
    .setPrivateKey(credentials.private_key)
    .setIssuer(credentials.client_email)
    .setPropertyStore(PropertiesService.getScriptProperties())
    .setCache(CacheService.getScriptCache())
    .setScope('https://www.googleapis.com/auth/contacts');
}

//JSONキーファイルを取得して返す関数
function getServiceAccountCredentials() {
  try {
    const content = DriveApp.getFileById(keyfile).getAs("application/json").getDataAsString("UTF-8");
    return content;
  } catch (e) {
    // エラーログを分かりやすくする
    const errorMessage = `GoogleドライブからのJSONキーファイルの読み取りに失敗しました。ファイルIDが正しいか、ファイルが存在するか、スクリプトの実行者に閲覧権限があるか確認してください。`;
    console.error(errorMessage + ` (ID: ${keyfile})`);
    // エラーを再スローして処理を停止
    throw new Error(errorMessage);
  }
}

//サービスアカウントの認証情報をプロパティストアとキャッシュから削除します。
function clearOAuthServiceToken() {
  try {
    //サービスを呼び出す
    const service = getOAuthService();
    
    // リセット実行
    service.reset();
    
    console.log('認証トークンを正常にクリアしました。 (Service: PeopleAPI_ServiceAccount)');
    
  } catch (e) {
    console.log('認証情報のクリアに失敗しました: ' + e.message);
  }
}

メインの連絡先編集スクリプト

メインのコントロール用関数

後述の各種補助関数をコントロールする司令塔の関数です。syncAllContacts関数が実行すべき関数となります。syncContactsForUser関数が個別のtargetリストのユーザーに対しての同期処理をコントロールする副司令のポジションの関数になります(ここでサービスアカウント認証が要求されています)。

//メインの実行関数
function syncAllContacts() {
  const ss = SpreadsheetApp.openById(ssid);

  const targetSheet = ss.getSheetByName("target");
  if (!targetSheet) {
    console.error(`同期対象者リストのシート(target)が見つかりません。`);
    return;
  }
  const TARGET_USERS = targetSheet.getRange('A2:A' + targetSheet.getLastRow())
                                  .getValues()
                                  .map(row => row[0])
                                  .filter(email => email && email.includes('@'));
  if (TARGET_USERS.length === 0) {
    console.warn('同期対象のユーザーが1人もいません。処理を終了します。');
    return;
  }

  const contactSheet = ss.getSheetByName("userlist");
  if (!contactSheet) {
    console.error(`連絡先データのシート(userlist)が見つかりません。`);
    return;
  }
  const masterContacts = getSheetData(contactSheet);

  TARGET_USERS.forEach(userEmail => {
    console.log(`${userEmail} への同期を開始します`);
    try {
      syncContactsForUser(userEmail, masterContacts);
      console.log(`${userEmail} への同期が正常に完了しました`);
    } catch (e) {
      console.error(`${userEmail} への同期中にエラーが発生しました: ${e.message}`);
      console.error(`エラー詳細: ${e.stack}`);
    }
  });
}

//ユーザーの連絡先の同期処理を行う処理
function syncContactsForUser(userEmail, masterContacts) {
  const service = getOAuthService();
  service.setSubject(userEmail);

  if (!service.hasAccess()) {
    throw new Error(`OAuth認証失敗: ${service.getLastError().message}`);
  }
  const headers = { 'Authorization': 'Bearer ' + service.getAccessToken() };

  const existingContacts = getManagedContacts(headers);

  masterContacts.forEach(masterContact => {
    const sheetId = masterContact.uid.toString();
    const existingContact = existingContacts[sheetId];

    if (existingContact) {
      if (isContactModified(masterContact, existingContact)) {
        console.log(`  更新: ${masterContact.fullName} (UID: ${sheetId})`);
        updateContact(headers, masterContact, existingContact);
      }
      
      delete existingContacts[sheetId];
    } else {
      console.log(`  作成: ${masterContact.fullName} (UID: ${sheetId})`);
      createContact(headers, masterContact);
    }
  });

  Object.keys(existingContacts).forEach(sheetId => {
    const contactToDelete = existingContacts[sheetId];
    const displayName = contactToDelete.names?.[0]?.displayName || '名前不明の連絡先';
    console.log(`  削除: ${displayName} (UID: ${sheetId})`);
    deleteContact(headers, contactToDelete.resourceName);
  });
}

個別の作業用関数

司令塔の関数から呼び出されて動作する補助関数です。シートデータの読み込み、相手の連絡先との比較用関数、相手の連絡先の取得関数、新規連絡先追加関数、連絡先の更新用関数、連絡先の削除用関数といった一連の作業を全て用意しています。

全ての連絡先には「SCRIPT_MANAGED_TAG」変数の値が経歴フィールドに追加されて、この値をがある連絡先だけを対象とし、追加や比較、追加、更新、削除が実施されます。それぞれのケースで既に存在してるならば何もしないで処理を終えます。

//スプレッドシートからデータを読み込み、オブジェクトの配列として返す補助関数
function getSheetData(sheet) {
  const range = sheet.getDataRange();
  const values = range.getValues();
  const headers = values.shift();
  const idIndex = headers.findIndex(header => header.toLowerCase().trim() === 'uid');
  
  if (idIndex === -1) {
    throw new Error('ヘッダーに "uid" 列が見つかりません。');
  }

  return values
    .filter(row => row[idIndex] && row[idIndex].toString().trim() !== '')
    .map(row => {
      const contact = {};
      headers.forEach((header, i) => {
        const key = header.toString().trim();
        contact[key] = row[i];
      });
      return contact;
    });
}

//相手ユーザーの連絡先を取得する処理補助関数
function getManagedContacts(headers) {
  const managedContactsMap = {};
  let nextPageToken = null;
  const baseUrl = `${endpoint}people/me/connections`;
  
  do {
    let url = `${baseUrl}?personFields=names,emailAddresses,phoneNumbers,organizations,biographies,metadata,userDefined&pageSize=1000`;
    if (nextPageToken) url += `&pageToken=${nextPageToken}`;

    const response = UrlFetchApp.fetch(url, { headers: headers, muteHttpExceptions: true });
    const result = JSON.parse(response.getContentText());

    if (response.getResponseCode() !== 200) throw new Error(`連絡先取得エラー: ${result.error.message}`);

    if (result.connections) {
      result.connections.forEach(person => {
        // メモ欄に保存された管理用タグを探す
        const bio = person.biographies?.find(b => b.value.startsWith(SCRIPT_MANAGED_TAG));

        if (bio) {
          // タグからIDを抽出 (例: "ManagedByGAS...:123" -> "123")
          const sheetId = bio.value.split(':')[1];
          if (sheetId) {
            managedContactsMap[sheetId] = person;
          }
        }
      });
    }
    nextPageToken = result.nextPageToken;

  } while (nextPageToken);
  
  return managedContactsMap;
}

//スプレッドシートのデータとGoogleコンタクトのデータを比較し、変更があるか判定する補助関数
function isContactModified(masterContact, existingContact) {
  // スプレッドシート側の名前をパース
  const nameParts = parseName(masterContact.fullName);
  
  // 既存の連絡先から名前を取得
  const existingGivenName = existingContact.names?.[0]?.givenName || '';
  const existingFamilyName = existingContact.names?.[0]?.familyName || '';

  // 修正点: 姓名で比較
  if (nameParts.givenName !== existingGivenName) return true;
  if (nameParts.familyName !== existingFamilyName) return true;

  // 以下は元のコードと同じ(比較項目を組織や役職にも広げています)
  const masterEmail = masterContact.email || '';
  const existingEmail = existingContact.emailAddresses?.[0]?.value || '';
  if (masterEmail !== existingEmail) return true;
  
  const masterPhone = masterContact.phone || '';
  const existingPhone = existingContact.phoneNumbers?.[0]?.value || '';
  if (masterPhone !== existingPhone) return true;

  const masterOrg = masterContact.organization || '';
  const existingOrg = existingContact.organizations?.[0]?.name || '';
  if (masterOrg !== existingOrg) return true;

  const masterTitle = masterContact.title || '';
  const existingTitle = existingContact.organizations?.[0]?.title || '';
  if (masterTitle !== existingTitle) return true;

  return false; // 変更なし
}

//連絡先を新規に作成する処理補助関数
function createContact(headers, contactData) {
  const url = `${endpoint}people:createContact`;

  // fullNameを姓と名に分割
  const nameParts = parseName(contactData.fullName);

  const payload = {
    //姓名にわけて登録する
    names: [{ 
      givenName: nameParts.givenName, 
      familyName: nameParts.familyName 
    }],
    emailAddresses: [{ value: contactData.email }],
    phoneNumbers: [{ value: contactData.phone }],
    organizations: [{ name: contactData.organization, title: contactData.title }],
    biographies: [{ value: `${SCRIPT_MANAGED_TAG}:${contactData.uid}` }]
  };

  const options = { method: 'post', contentType: 'application/json', headers: headers, payload: JSON.stringify(payload), muteHttpExceptions: true };

  const response = UrlFetchApp.fetch(url, options);

  if (response.getResponseCode() !== 200) console.error(`作成エラー (${contactData.fullName}): ${response.getContentText()}`);
}

//連絡先を更新する処理補助関数
function updateContact(headers, masterContact, existingContact) {
  const resourceName = existingContact.resourceName;
  const url = `${endpoint}${resourceName}:updateContact?updatePersonFields=names,emailAddresses,phoneNumbers,organizations`;

  // fullNameを姓と名に分割
  const nameParts = parseName(masterContact.fullName);

  const payload = {
    etag: existingContact.etag,
    // givenNameとfamilyNameに姓名をわけて登録
    names: [{ 
      givenName: nameParts.givenName, 
      familyName: nameParts.familyName 
    }],
    emailAddresses: [{ value: masterContact.email }],
    phoneNumbers: [{ value: masterContact.phone }],
    organizations: [{ name: masterContact.organization, title: masterContact.title }]
  };

  const options = { method: 'patch', contentType: 'application/json', headers: headers, payload: JSON.stringify(payload), muteHttpExceptions: true };

  const response = UrlFetchApp.fetch(url, options);

  if (response.getResponseCode() !== 200) console.error(`更新エラー (${masterContact.fullName}): ${response.getContentText()}`);
}

//連絡先を削除する処理補助関数
function deleteContact(headers, resourceName) {
  const url = `${endpoint}${resourceName}:deleteContact`;
  const options = { method: 'delete', headers: headers, muteHttpExceptions: true };
  const response = UrlFetchApp.fetch(url, options);

  if (response.getResponseCode() !== 200) console.error(`削除エラー (resourceName: ${resourceName}): ${response.getContentText()}`);
}

//fullName列の値を「名 (givenName)」と「姓 (familyName)」に分割する
function parseName(fullName) {
  if (!fullName) {
    return { givenName: '', familyName: '' };
  }
  
  // 全角・半角スペースで分割
  const parts = fullName.trim().split(/[\s ]+/); 

  if (parts.length === 1) {
    return { givenName: parts[0], familyName: '' };
  } else {
    const familyName = parts[0];
    const givenName = parts.slice(1).join(' '); 
    return { givenName: givenName, familyName: familyName };
  }
}

実行結果

サービスアカウントとドメイン全体の委任を利用しての実行ユーザーの「なりすまし」を利用して、他のドメイン内ユーザーの連絡先を一括で編集することが出来ました。全て、新規作成時にSCRIPT_MANAGED_TAGの値がbiographiesフィールドに書き込まれるので、この値を元に同期先対象かどうかを判定しています。

  1. userlistの連絡先を一件ずつチェックし、targetのユーザの連絡先と照合します
  2. 更新が発生するケース(情報が書き換わってる場合)には、更新作業が走ります。
  3. 相手先に存在しなかった場合には、連絡先に新規作成の処理が走ります。
  4. 何も差分が無い場合には更新も追加もせずにスルーします。
  5. スプシにない連絡先が残ってる場合には、削除を実行します。

SCRIPT_MANAGED_TAGの無い連絡先については、なんら処理対象になりません。注意点として、uidの値を変更してしまうと別人になってしまう為、重複して登録されてしまう可能性があります。

図:他のユーザの連絡先が変更されました

注意点

今回のコード、前回の連絡先を弄るコードで使っていたサービスにPeopleapiを使っての手法ではなく、REST APIとしてのPeople APIを使って処理をしています。そのため、UrlfetchAppを使ってリクエストを都度投げていますが、これは「GASのサービスを使った手法ではなりすまし処理が出来ない」ことが理由。

かならず実行者のアクセストークンが使われる関係で、相手先ユーザーのトークンが使えない為、自分の連絡帳だけが延々と更新されつづけることになります。

REST APIを使うケースではサービスアカウントがドメイン全体の委任を受けてるため、全ユーザーのAccess Tokenを利用可能となり、相手のアカウントの代理をして、本来は出来ない連絡先の更新処理を実現しています。

但し、UrlfetchAppを使っている関係で、処理が少々遅めであるため、userlistの数やtargetのユーザが多い場合には、タイムアウトする可能性がありますので、大人数の場合には6分の壁を超えるような処理や、UrlfetchApp.fetchAllを使った手法を追加で実装が必要となります。

Google Apps Scriptで6分の壁(タイムアウト)を突破する【GAS】

AppSheet連携

今回の機能は組織の連絡先を全員に同期する手法ですが、この仕組みを利用してAppSheetで名刺管理のアプリ側から、顧客情報を連絡先としてそのまま追加するという連携も可能です。

以下のエントリーを元にアプリを作成してぜひ連携してみると、小粒ながらかなり強力な営業ツールに化けます。

AppSheetで名刺読み取り管理ツールを作る【GAS】

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)