💻

作ったものを晒すシリーズ#2:カオナビAPIを駆使してSlackのプロファイルを充実させるスクリプト

2022/09/30に公開約11,000字

初めに

皆さん、Slack使ってますか?
うちの会社ではエンジニア・非エンジニア問わず全社共通のコミニュケーションツールとして日々頑張ってくれています。Slackは友達!

折角Slackを導入しているんだから、バリバリ活用しないと損!ということで、ありとあらゆるサービスをSlack経由で操作したり、サービスと連携して色々したりして、Slackを軸としたChatOps環境の構築を目指しているふぁじぃです。

前置きが長くなりましたが、作ったものを晒すシリーズ第二弾「カオナビと連携してSlackのプロフィールを充実させるスクリプトを作ったよ!」を書いていこうと思います。

実装の目的

  • 会社では基本Slackをコミュニケーションツールとして活用し、入社した人には必ずSlackアカウントが割り振られる
    • 基本Slackを通してコミュニケーションするので、顔や名前を直接知らない人も多い
    • みんなSlackのプロフィールを進んで更新しない為、Slackのプロフィールが役に立っていない
    • 会社で利用している人事DBのSaaS(カオナビ)には情報が登録されているが、 正直見に行くのが面倒臭い
    • じゃあカオナビから一部の情報持ってきてSlackのプロフィールに設定するようにしよう

こんな機能

スクリプトを実行することで、以下の項目がカオナビからSlackのプロフィールへと連携されます。
全社員分が一斉に更新される仕組みです。

  • 所属部署・グループ・チーム
  • 漢字指名と読み仮名
  • 業務内容
  • カオナビのURL

流石にカオナビ側の項目が多すぎるので、最低限の情報に絞って連携し、カオナビの個別ページのURLを設定することでSlackから直接カオナビへの導線を作り、誘導するようにしています。

用意する(した)もの

  • カオナビのAPIアクセス情報
  • Slackアプリ
  • GASのローカル開発環境
  • 効率化するぞという意気込みと気合と時間
  • くじけない心

開発環境と動作環境

スクリプトの実装はGAS(Google App Script) + clasp + TypeScriptによるローカル開発で行なっています。

そこまで大それた機能ではなく、短期間で手軽に作れて手軽に修正できることを考えていた為、GASを用いて実装しています。このスクリプトは毎日定期的に実行させるため、トリガーの設定が簡単にできるというのも理由の一つです。TypeScriptを使っているのは、前回の記事でも書いた通り自分が静的型付けな言語に慣れていたからというのが一番の理由です。

また、カオナビと連携した開発が今後もあることを見据えて、GASで使える、APIをラップしたライブラリを自前で作っています。

実装

0. 全体実装

function execute() {
    // カオナビAPI用Connectorを作成
    const connector = createKaonabiApiConnector();
    // カオナビのユーザー一覧取得
    const kaonabiMemberList = connector.member.getAllMemberInfo({});

    // Slackからユーザーのリストを取得
    const scriptProperties = PropertiesService.getScriptProperties();
    const userToken = scriptProperties.getProperty("SLACK_APP_USER_TOKEN");
    const userListResult = UrlFetchApp.fetch("https://slack.com/api/users.list",{
      method: "get",
      contentType: "application/json",
      headers: {
        Authorization: `Bearer ${userToken}`  
      }
    });
    const slackUserListJson = JSON.parse(userListResult.getContentText());
    
    /* 取得したユーザー分処理 */
    slackUserListJson.members.forEach((member=>{
      if (member.is_bot || member.is_app_user || member.deleted) {
        // 削除済み・アプリケーションユーザー・ボットユーザーの場合はスルー
        return;
      }
      // Emailと一致するカオナビメンバーを取得
      const slackEmail = member.profile.email;
      console.log(`Slack_User: ${slackEmail}`)

      const kaonabiRecord = kaonabiMemberList.member_data.find(memberData=>{
        if(memberData.mail === slackEmail){
          return memberData;
        }
      });
      if(!kaonabiRecord){
        return;
      }

      /* Slackにセットするカオナビの情報を取得・整形 */
      // カオナビURL
      const kaonabiUrl = `https://service.kaonavi.jp/personal/detail/pb?member_id=${kaonabiRecord.id}`;

      // 所属
      const departmentNames = kaonabiRecord.department.names;
      const lastDepartment = departmentNames[departmentNames.length-1];
      if(lastDepartment === "退職者"){
        // 退職者の場合はスルー
        return;
      }
      if(/仙台..*/.test(lastDepartment)){
        // 仙台メンバーは「仙台メンバー(所属)」の形に整形する
        if(lastDepartment != "仙台メンバー"){
          const tempDep = lastDepartment.split("仙台");
          departmentNames[departmentNames.length-2] = `仙台メンバー(${tempDep[1]}`;
          departmentNames.pop();
        }
      }
      const departmentName = departmentNames.join(" ").replace("---- ","").replace("株式会社 ○○○○○○ ","");

      // 業務内容
      const workField = kaonabiRecord.custom_fields.find(field=>{
        if(field.id == 114){
          return field;
        }
      });
      const workValue = (function(){
        if(workField) {
          return workField.values[0];
        }
        // 業務内容を記載していない人はundefinedになる
        return "";
      })();

      // 漢字
      const nameKanji = kaonabiRecord.name;
      // 読み仮名
      const nameKana = kaonabiRecord.name_kana;

      /* Slackに情報をセット */
      do{
          const setProfileResult = UrlFetchApp.fetch("https://slack.com/api/users.profile.set",{
            method: "post",
            contentType: "application/json",
            headers: {
              Authorization: `Bearer ${userToken}`  
            },
            payload: JSON.stringify({
              token: userToken,
              user: member.id,
              profile: {
                // 所属
                title: departmentName,
                fields: {
                  Xf03JYKA47NY: {
                    // 読み方
                    value: nameKanji + `${nameKana}`,
                    alt: ""
                  },
                  Xf03PB97TYBV: {
                    // カオナビURL
                    value: kaonabiUrl,
                    alt: kaonabiUrl
                  },
                  Xf03PBAYDM5G: {
                    // 業務内容
                    value: workValue,
                    alt: ""
                  }
                }
              }
            })
          });

          // Retry-Afterがヘッダに存在する場合(レート制限超過時)は、その時間分Sleepしてから以降の分を処理
          const headers = setProfileResult.getHeaders();
          const retryAfterHeader = headers["Retry-After"];
          if(retryAfterHeader){
            const retryAfterSecond = Number.parseInt(retryAfterHeader);
            console.log(`'Retry-After' header is exist. Sleep for ${retryAfterSecond} second.`)
            Utilities.sleep(retryAfterSecond * 1000);
          }else{
            break;
          }
      }while(true);
    }));
}

/**
 * カオナビAPIアクセス用コネクターを作成
 * @returns コネクタインスタンス
 */
function createKaonabiApiConnector(): KaonabiApiConnector.KaonabiApiConnector {
    const scriptProperties = PropertiesService.getScriptProperties();
    const consumerKey = scriptProperties.getProperty("KAONABI_CONSUMER_KEY");
    const consumerSecret = scriptProperties.getProperty("KAONABI_CONSUMER_SECRET");
  
    const kaonabiApiConnector = KaonabiApiConnector.KaonabiApiConnector.initCreate(
      {
        consumerKey: consumerKey as string,
        consumerSecret: consumerSecret as string,
        grantType: "client_credentials",
      },
      KaonabiApiConnector.GASCommunication
    );
  
    return kaonabiApiConnector;
}

1. Slackからユーザーのリストを取得

// Slackから全ユーザーのリストを取得
const scriptProperties = PropertiesService.getScriptProperties();
const userToken = scriptProperties.getProperty("SLACK_APP_USER_TOKEN");
const userListResult = UrlFetchApp.fetch("https://slack.com/api/users.list",{
	method: "get",
	contentType: "application/json",
	headers: {
		Authorization: `Bearer ${userToken}`  
	}
});
const slackUserListJson = JSON.parse(userListResult.getContentText());

SlackのAPIを利用して、Slack Workspace上の全ユーザーのリストを取得します。
ここで利用しているAPIは users.list です。
認証に必要なユーザートークンは、事前にSlackから取得してGASのスクリプトプロパティに設定の上、コード上から取得しています。ベタ打ちダメ絶対。

2. カオナビから全メンバーのリストを取得

// カオナビAPI用Connectorを作成
const connector = createKaonabiApiConnector();
// カオナビのユーザー一覧取得
const kaonabiMemberList = connector.member.getAllMemberInfo({});

カオナビAPI用Connectorについては自前のライブラリとして実装しています。
connector.member::APIに対応するメソッド(オプション)
とすることで、APIからのレスポンスをマップしたオブジェクトとして返却してくれます。

3. 取得したSlackユーザー分繰り返し処理

 /* 取得したユーザー分処理 */
slackUserListJson.members.forEach((member=>{
	if (member.is_bot || member.is_app_user || member.deleted) {
		// 削除済み・アプリケーションユーザー・ボットユーザーの場合はスルー
		return;
	}
	
	// Emailと一致するカオナビメンバーを取得
	const slackEmail = member.profile.email;
	const kaonabiRecord = kaonabiMemberList.member_data.find(memberData=>{
		if(memberData.mail === slackEmail){
		  return memberData;
		}
	});
	if(!kaonabiRecord){
		return;
	}

取得したSlackユーザー分繰り返し処理を行います。
が、取得したユーザーには既に削除されたユーザーやBotユーザーも含まれてくるため、それらの場合はスキップして次のユーザーの処理を行なっています。

処理対象のユーザーの場合、事前に取得したカオナビ側のユーザーリストから、Slackのメールアドレスと一致するレコードを取得します。この時、カオナビ側に一致するユーザーがいない場合はスキップして次のユーザーの処理を行います。

4. Slackにセットする情報を整形

/* Slackにセットするカオナビの情報を取得・整形 */
// カオナビURL
const kaonabiUrl = `https://service.kaonavi.jp/personal/detail/pb?member_id=${kaonabiRecord.id}`;

// 所属
const departmentNames = kaonabiRecord.department.names;
const lastDepartment = departmentNames[departmentNames.length-1];
if(lastDepartment === "退職者"){
	// 退職者の場合はスルー
	return;
}
if(/仙台..*/.test(lastDepartment)){
	// 仙台メンバーは「仙台メンバー(所属)」の形に整形する
        if(lastDepartment != "仙台メンバー"){
		const tempDep = lastDepartment.split("仙台");
		departmentNames[departmentNames.length-2] = `仙台メンバー(${tempDep[1]}`;
		departmentNames.pop();
        }
}
const departmentName = departmentNames.join(" ").replace("---- ","").replace("株式会社 ○○○○○○ ","");

// 業務内容
const workField = kaonabiRecord.custom_fields.find(field=>{
	if(field.id == 114){
		 return field;
	}
});
const workValue = (function(){
	if(workField) {
		return workField.values[0];
	}
	// 業務内容を記載していない人はundefinedになる
	return "";
})();

// 漢字
const nameKanji = kaonabiRecord.name;
// 読み仮名
const nameKana = kaonabiRecord.name_kana;

Slackに設定する各種の情報を整形しています。
所属上、「退職者」となっているユーザーについてはスキップしています。

カオナビの「所属」項目は配列で返却されてきます。例えば、「システム部 情報システムグループ 情報システムチーム」といった場合、それぞれが配列の値として格納されてくる仕様です。

そのため、セット用の文字列とする際にはスペース区切りでjoinさせているんですが、
うちの会社は仙台にサポート拠点を持っており、仙台メンバーだけチームの下に○○担当のような構成ととなっています。その為、仙台メンバーに限り所属の最後の表記を「仙台メンバー(所属)」のようにして、表記上分かりやすくするような作りとなっています。

5. Slackへと情報をセット

/* Slackに情報をセット */
do{
  const setProfileResult = UrlFetchApp.fetch("https://slack.com/api/users.profile.set",{
    method: "post",
    contentType: "application/json",
    headers: {
      Authorization: `Bearer ${userToken}`  
    },
    payload: JSON.stringify({
      token: userToken,
      user: member.id,
      profile: {
	// 所属
	title: departmentName,
	fields: {
	  Xf03JYKA47NY: {
	    // 読み方
	    value: nameKanji + `${nameKana}`,
	    alt: ""
	  },
	  Xf03PB97TYBV: {
	    // カオナビURL
	    value: kaonabiUrl,
	    alt: kaonabiUrl
	  },
	  Xf03PBAYDM5G: {
	    // 業務内容
	    value: workValue,
	    alt: ""
	  }
	}
      }
    })
  });

  // Retry-Afterがヘッダに存在する場合(レート制限超過時)は、その時間分Sleepしてから以降の分を処理
  const headers = setProfileResult.getHeaders();
  const retryAfterHeader = headers["Retry-After"];
  if(retryAfterHeader){
    const retryAfterSecond = Number.parseInt(retryAfterHeader);
    console.log(`'Retry-After' header is exist. Sleep for ${retryAfterSecond} second.`)
    Utilities.sleep(retryAfterSecond * 1000);
  }else{
    break;
  }
}while(true);
}));

Slack API経由でユーザーのプロフィールに情報を設定します。
ここで利用しているAPIは users.profile.set です。このAPIはプロフィールの設定のほか、ステータス文字列や絵文字の設定もできる優れものです。

ペイロードとして送信するprofileについては公式のAPIの説明ページに大方記載がありますが、標準に用意されている項目については、対応するキーが用意されているので、そのキーに対して設定するバリューを記載すれば良いです。

今回、カオナビURLと業務内容については後から追加しているオリジナル項目のため、filedsというキーに設定するオブジェクト内に、それぞれの項目のフィールドIDと呼ばれるIDをキーとしてオブジェクトを設定する必要があります。
このフィールドIDについては、Slackの管理画面から「プロフィール」を辿り、対象項目を「APIが設定する項目」に変更することで確認できます。

また、数百人近くのプロフィールを繰り返し設定するため、Slackのレート制限に引っかかる可能性を考慮し、APIの実行結果のヘッダーに「retry-after」が存在した場合、retry-afterに設定されている時間分待機した上で再度処理を実行するようにしてあります。

作成後

このスクリプトを作成する前までは、「この人の名前、ローマ字表記ではわかるけど漢字名なんだっけ...?」ということが結構あり、その度にわざわざAzure ADのアプリパネル経由でカオナビを開いて検索して... という手間がかかっていましたが、Slack上からユーザー名を選択してプロファイルから一発で分かるようになったので、非常に楽になりました。

○○さんという風に名指しで書くことも多く、珍しい漢字の人だと間違ったら失礼だし... というシチュエーションも多かったので、そういった点では非常に有用です。

ただ、業務内容などをカオナビから引っ張ってくるんですが、カオナビの業務内容の記載は任意のため、現状記載している人が少なく、あまり業務内容の項目が意味をなしていなかったりします。
今後は、カオナビ側の情報を充実させる事を促す為の施策を検討する必要がありそうです。

次回の予定

次回晒すのは、

「リモート環境下における環境アクセスの承認依頼のフローをSlackで実装してみた」

です。(予定は未定)

Discussion

ログインするとコメントできます