📍

Slack ゲストアカウントの棚卸・通知をGASで作ってみた ver.2

2022/08/16に公開

はじめに

本業・副業共にコミュニケーションツールはSlackを使用しているのですが、ゲストアカウントの棚卸って結構課題だったりすることが多いと思います。自分も多分に漏れず、「後で効率化したいな〜、何かScript書こうかな〜」と思いながら1年以上経過してしまいました。。。

過去の経緯から有効期限を設定してないものもあったり、IT統制などでアカウントの棚卸を効率的に行いたいなど、みんな似たような課題感はあるのかなと思い、Slackチャンネルへ通知してくれるScriptを作成してみました

公式に機能なかったっけ?

はい、公式にも期限切れ通知の機能はあります。ただし、

  • 設定した管理者 + 対象者にのみ通知
  • 5日前に通知(固定)

と、使い勝手は良くはありません...

本当は...Slackコネクトしたいよね

Slackコネクトしている場合であれば、相手側のアカウント管理まで実施する必要はありません。
とはいえ、相手側がSlackを使っていなかったり無料プランだったりすると、ゲストユーザーとして招待するしかないのが現状です。

ゲストユーザーとSlackコネクトの違いについては下記を参照ください
https://note.com/humihumi_infosys/n/ne8a5695d1aea

https://slack.com/intl/ja-jp/help/articles/202518103-Slack-でのゲストのメンバー種別を理解する

完成形

さっそく、「こんな感じでできたよ!」 というのを披露したいと思います〜

想定している運用手順

Slack側

1ユーザーのみの場合

複数ユーザーいる場合

スプレッドシート側

作成手順

01.Slackアプリの作成

  1. Slackのホーム画面の左メニューから、[App 管理] をクリックする

  2. 右上の [ビルド] をクリックする

  3. [Create New App] > [From scratch] をクリックする

  4. アプリ名と対象のワークスペースを選択して、[Create App] をクリックする

  5. Basic Informationの画面をスクロールし、「Display Information」の情報を入力し、[Save Changes] をクリックする

  6. 左メニューの [Incoming Webhooks] > 「Activate Incoming Webhooks」のトグルを [On] にする

  7. 左メニューの [App Home] > [Edit] > Display NameとDefault usernameを入力して [Add] をクリックする

  8. 左メニューの [OAuth & Permissions] > Scopes から、下記のScopeを追加する

  9. [OAuth & Permissions]の上部に戻り、[Instal to Workspace] をクリックする

  10. インストール先のチャンネルを指定して、[許可する] をクリックする

  11. [Bot Useer OAuth Token] をコピーする

  12. [Webhook URL] をコピーする

02.GAS(Google Apps Script)の作成

  1. スプレッドシートを新規作成し、「format」というシート名にする(サンプルは こちら

  1. シートに以下の条件付き書式を設定する

  2. メニューの [拡張機能] > [Apps Script] をクリックする

  3. プロジェクトの設定画面から、[スクリプト プロパティを編集] をクリックし、以下のように設定する

プロパティ
BOT_TOKEN 1-11でコピーした値
WEBHOOK_URL 1-12でコピーした値
SHEET_ID 2-1で作成したスプシのID
SHEET_NAME 2-1で作成したスプシのシート名
EXPIRE_DATE 今回チェック対象となる有効期限

  1. エディタから、scriptファイルを4つ (config.gs / main.gs / functsions.gs / remind.gs) を作成する

  2. 以下のソースコードを貼り付ける

config.gs
/* ===========================================

  プロパティ情報の取得

=========================================== */

const SHEET_ID    = PropertiesService.getScriptProperties().getProperty("SHEET_ID");
const SHEET_NAME  = PropertiesService.getScriptProperties().getProperty("SHEET_NAME");
const BOT_TOKEN   = PropertiesService.getScriptProperties().getProperty("BOT_TOKEN");
const WEBHOOK_URL = PropertiesService.getScriptProperties().getProperty("WEBHOOK_URL");
const EXPIRE_DATE = PropertiesService.getScriptProperties().getProperty("EXPIRE_DATE");
main.gs
/* ===========================================

  処理全体

=========================================== */

function main() {
 
  // 15日前に特定の処理をさせたい
  if (checkExpiryDate(EXPIRE_DATE) <= 14 || 15 <= checkExpiryDate(EXPIRE_DATE)) {

    return;

  } else {

    // formatシートをコピーし、今回棚卸しに使うシートの作成とシート名の取得
    const sheetInfo = copySheet(SHEET_ID, SHEET_NAME);

    // ユーザー情報の取得
    const guestUsersInfo = getSlackGuestUsersInfo(BOT_TOKEN);

    // スプシに書き込み
    writeExpireDateToSheet(EXPIRE_DATE, SHEET_ID, sheetInfo[0]);
    writeGuestUserToSheet(guestUsersInfo, SHEET_ID, sheetInfo[0]);

    // 本文(その1:メッセージ部分)作成
    const prebody =
    {
      "blocks": [
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "<!channel>" + "\n"
              + "Slackのゲストユーザーの有効期限をお知らせするよ!:jikan-nya:",
          }
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "有効期限を延長する場合は <https://xxxxxxx.slack.com/admin|ユーザー管理> から〜 :black_cat:" + "\n"
              + "最終ログイン日の確認は <https://xxxxxxx.slack.com/admin/stats|アナリティクス> から〜 :mag:" + "\n\n"
              + "https://docs.google.com/spreadsheets/d/" + SHEET_ID + "/edit#gid=" + sheetInfo[1]
          },
        },
      ]
    }
  
    // Slackへ通知(その1)
    postSlack(prebody, WEBHOOK_URL);

    // 本文(その2:通知ユーザー部分)作成
    membersInfo.forEach(function(member) {
    
      // 有効期限が15日より先の場合はスルー
      if (member[5] == "" || checkExpiryDate(member[5]) > 15) { return; };
 
      // 本文
      const mainbody =
      {
        "blocks": [
          {
            "type": "divider"
          },
        ],
        "attachments" : [
          {
            "fallback": "Slackのゲストユーザーの有効期限をお知らせするよ!:jikan-nya:",
            "color": "#fec20f",
            "fields": [
              {
                "title": "`ゲストアカウント名`",
                "value": member[1],
                "short": true
              },
              {
                "title": "`メールアドレス`",
                "value": member[2],
                "short": true
              },
              {
                "title": "`有効期限`",
                "value": member[5],
                "short": true
              },
              {
                "title": "招待者",
                "value": member[7],
                "short": true
              },
            ],
          }
        ],
      }

      // Slackへ通知(その2)
      postSlack(mainbody, WEBHOOK_URL);
  
    });
  }

  return;

}
functions.gs
/* ===========================================

  Slack APIでゲストユーザー一覧を取得する

=========================================== */

function getSlackGuestUsersInfo(slackBotToken) {

  // ユーザー情報を取得する
  const userList = getUserList(slackBotToken);

  // 変数の初期化
  let guestUsersInfo  = [];
  let ultraRestricted = "";
  let isInvitedUser   = "";
  let guestInvitedBy  = "";
  let guestExpiryDate = "";
  let guestUserName   = "";

  userList.forEach(function(members) { 
    members.forEach(function(member) {

      // 削除済みユーザー or ゲストユーザー以外 は除外
      if (member["deleted"] === true || member["is_restricted"] === false) { return; };

      // ゲストユーザーがシングル or マルチ?
      ultraRestricted = member["is_ultra_restricted"] === true ? "シングル" : "マルチ";

      // 招待ステータスがtrue?undifined?
      isInvitedUser = member["is_invited_user"] === undefined ? "" : "true";

      // ゲストの有効期限が入力されてたら、UnixTime → JSTに変換
      guestExpiryDate = member["profile"]["guest_expiration_ts"] === undefined ? "" : changeJSTfromUnixTime(member["profile"]["guest_expiration_ts"]);

      // 招待したユーザーIDがundifinedの場合 → 空白に変換
      guestInvitedBy = member["profile"]["guest_invited_by"] === undefined ? "" : member["profile"]["guest_invited_by"];

      // 招待したユーザーID → ユーザー名に変換
      guestUserName = getUserNamefromInvitedId(guestInvitedBy, BOT_TOKEN);

      // ユーザー情報をオブジェクトに入れる
      guestUsersInfo.push([
        member["id"],               // ユーザーID
        member["real_name"],        // 氏名
        member["profile"]["email"], // メールアドレス
        ultraRestricted,            // ゲストの種類
        isInvitedUser,              // true:招待中
        guestExpiryDate,            // ゲストの有効期限
        guestInvitedBy,             // 招待したユーザーID
        guestUserName,              // 招待したユーザー名
      ]);
    });
  })
  return guestUsersInfo;
 
}



/* ===========================================

  スプレッドシートに取得した今回対象の有効期限を書き込む

=========================================== */

function writeExpireDateToSheet(expireDate, sheetId, inventorySheetName) {

  //  スプレッドシート読み込み
  const spreadsheet = SpreadsheetApp.openById(sheetId);
  const sheet       = spreadsheet.getSheetByName(inventorySheetName);
  
  // 有効期限をセットする
  sheet.getRange(2, 2).setValue(expireDate + " 23:59:59");

}

/* ===========================================

  スプレッドシートに取得したゲストユーザー一覧を書き込む

=========================================== */

function writeGuestUserToSheet(membersInfo, sheetId, inventorySheetName) {

  //  スプレッドシート読み込み
  const spreadsheet = SpreadsheetApp.openById(sheetId);
  const sheet       = spreadsheet.getSheetByName(inventorySheetName);
  
  // ユーザー情報をセットする
  sheet.getRange(6, 2, membersInfo.length, membersInfo[0].length).setValues(membersInfo);

}


/* ===========================================

  Slackへ投稿する

=========================================== */

function postSlack(body, postUrl) {

  // 送信オプション
  const options = {
    method     : 'post',
    contentType: 'application/json',
    payload    : JSON.stringify(body),
  };
  
  // 送信処理
  UrlFetchApp.fetch(postUrl, options);

}


/* ===========================================

  Slackのユーザーリストを取得する

=========================================== */

function getUserList(slackBotToken) {
  
  // 1回の取得件数上限
  const limit = 1000;

  // 1,000件以上取得する際に必要なオプション類
  let cursor  = "";
  let isLoop  = true;
  let isFirst = true;

  // 格納する箱の初期化
  let userList = [];

  while(isLoop) {

    const options = {
      "method"     : "get",
      "contentType": "application/x-www-form-urlencoded",
      "payload": { 
        "token" : slackBotToken,
        "cursor": cursor,
        "limit" : limit
      }
    };
  
    const usersListUrl = "https://slack.com/api/users.list";
    const response     = UrlFetchApp.fetch(usersListUrl, options);
    const json         = JSON.parse(response.getContentText());
    userList.push(json["members"]);
    cursor = json.response_metadata.next_cursor;

    // 次ページ(1000件超のユーザー数)がなければループ終了
    if (isFirst === false && cursor === "") { isLoop= false; }
    isFirst = false;
  }

  return userList;

}


/* ===========================================

  UnixTimeからJSTに変換する

=========================================== */

function changeJSTfromUnixTime(unixTime) {

  const jstTime = Utilities.formatDate(new Date( unixTime * 1000 ), "JST", "yyyy/MM/dd HH:mm:ss");
  return jstTime;

}


/* ===========================================

  Slackのゲストユーザーの名前を招待IDから取得する

=========================================== */

function getUserNamefromInvitedId(guestInvitedBy, slackBotToken) {

  if (guestInvitedBy == "") { return };

  const options = {
    "method"     : "get",
    "contentType": "application/x-www-form-urlencoded",
    "payload": { 
      "token": slackBotToken,
      "user" : guestInvitedBy
    }
  };
  
  const usersInfoUrl = "https://slack.com/api/users.info";
  const response     = UrlFetchApp.fetch(usersInfoUrl, options);
  const userName     = JSON.parse(response).user["name"] === undefined ? "" : JSON.parse(response).user["name"];

  return userName;
  
}


/* ===========================================

  有効期限まで何日かをチェックする

=========================================== */

function checkExpiryDate(guestExpiryDate) {

  const expiryDate = new Date(guestExpiryDate);
  const now        = new Date();
  const termDay    = (expiryDate - now) / 86400000;

  return termDay;

}


/* ===========================================

  formatシートをコピーし、書き込み用のシートを作成する

=========================================== */

function copySheet(sheetId, sheetName) {
  
  // 一覧シートの情報を読み込む
  const spreadsheet = SpreadsheetApp.openById(sheetId);
  const formatSheet = spreadsheet.getSheetByName(sheetName);

  // シート名に入れる日付を取得する
  const sheetDate = Utilities.formatDate(new Date(), "JST", "yyyyMMdd");

  // formatシートをコピーし、シート名を変更する
  const taskSheet = formatSheet.copyTo(spreadsheet).setName(sheetDate);
  taskSheet.activate();
  spreadsheet.moveActiveSheet(1);

  // 返り値となる配列に値を格納する
  let sheetInfo = [];
  sheetInfo.push(sheetDate);
  sheetInfo.push(taskSheet.getSheetId());

  // 後続処理にシート名を使いたいので返り値として処理
  return sheetInfo;

}
remind.gs
/* ===========================================

  特定の日(◯日前)にリマインドさせたい

=========================================== */

function remind() {

  // 5日前 or 2日前
  if ((4 <= checkExpiryDate(EXPIRE_DATE) && checkExpiryDate(EXPIRE_DATE) <= 5) || (1 <= checkExpiryDate(EXPIRE_DATE) && checkExpiryDate(EXPIRE_DATE) <= 2)) {

    // 一覧シートの情報を読み込む
    const activeSheet = SpreadsheetApp.getActiveSpreadsheet();
    
    // 本文(メッセージ部分)作成
    const body =
    {
      "blocks": [
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "<!channel>" + "\n"
              + "Slackのゲストユーザーの有効期限のリマインドだよ!:chira-nya:" + "\n"
              + "■今回の有効期限:" + EXPIRE_DATE,
          }
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "有効期限を延長する場合は <https://xxxxxxx.slack.com/admin|ユーザー管理> から〜 :black_cat:" + "\n"
              + "最終ログイン日の確認は <https://xxxxxxx.slack.com/admin/stats|アナリティクス> から〜 :mag:" + "\n\n"
              + "https://docs.google.com/spreadsheets/d/" + SHEET_ID + "/edit#gid=" + activeSheet.getSheetId()
          },
        },
      ]
    }
  
    // Slackへ通知
    postSlack(body, WEBHOOK_URL);

  } else {

    return;

  }
}
  1. 送信テストとして、[▷実行] をクリックする

  2. [トリガー] > [トリガーを追加] をクリックする

  3. 任意の設定をして [保存] をクリックする

考慮した点

  • BotトークンやWebhookURLのプロパティ類は、ソースコードに記載せずに、GASのスクリプトプロパティ欄に記載
  • デフォルトの「users.list」だけだと、招待した人のユーザー名を取得できなかったので、取得処理を追加
  • 弊社環境では、謎に招待したユーザーIDが「undifined」になるパターンがあった為、処理を追加
  • 弊社環境では、ゲストの有効期限を一定統一しているため、複数ユーザーいる場合は、前半のメッセージ部分とユーザー情報部分を分けて通知しています
  • 内部統制関連で、棚卸情報の一覧化用としてスプレッドシートへも書き込んでいます
    • そのまま作業用シートとして、実際にチェックする従業員のみんなに書き込むことを想定
  • その他、雰囲気...

妥協した点

  • Slack上で有効期限を延長(無効化)させたい
    • どうやらAPIが、Enterprise Gridのみ利用可能ということで今回は諦め
  • 最終アクティブ日時の取得
    • ぱっと取得できるものがなさそうだったので妥協

参考にしたもの

その1

最初のきっかけはこれ。kajinariさんの記事を見ていいなぁと思ったところから。
https://medium.com/kajinari/slack-communication-with-collaborator-d4417992c03f

その2

同タイミングでヤスムラさんも同様の記事を上げていますが、コード見てみると結構違いがあって面白いです
https://note.com/yasuym1/n/n9a0fcc9439dc

さいごに

最近だと、BUNDLEジョーシス のようなSaaSの一元管理サービスなどが出ており、その中でもチェックできたりしますよね。

とはいえ、弊社では導入予定はなかったり、やっぱり「Slackに通知してくれないと中々気づけん!」みたいな人もいるかなと思います(僕ですね...

作ってみてやはり、Enterprise Gridいいなぁと思いました。ホント導入してる企業羨ましい...

Discussion