見出し画像

Slackのゲストアカウントを良い感じに管理したいのでGASで良い感じにしたよ

こんばんは、しがない情シスです。

今回はSlackゲストアカウントのお話です。

Slackゲストアカウント、悩みますよね〜。
今の職場で実際にゲストアカウントの取り扱いに触れてみて「これなんとかしないと大変なやつだよね…」と実体験しました。

私の場合、脈々と受け継がれる社内IT環境に手を加えるケースが多いので、社内のコンテキストの把握や、既存運用が「どうしてこうなったか?」という経緯の理解も重要になってきます。

というわけで、今回はメインはプログラミング半分、運用の改善半分な内容でお届けしようと思います。


まずはじめに

Slackゲストを管理しよう、と思ってみても考えることと、やりたいことがいっぱい出てきます。
アカウントを一つ作る、ということはその始まりから終わりまでを情シスを初めとした社内のリソースを使って管理運用していく、ということになりますね。

そういう悩みにぶちあたたったときには、はいこちらです。

a03さんのnoteがSlackゲストアカウントにまつわるお悩みごとを非常に良く言語化されており、改善していきたいポイントの一つのランドマークとしてとっても参考になります。
何のために、何を、どうしていきたいのか?そしてそのための手段はどんな物が良いのか…?などなど。

私は何遍も読み直して、「この組織にはこういう感じがいいかな!」というイメージを探っていきました。
先達の知恵に感謝を…!!

あ、それとあくまでここに提示するのは私が所属している組織に対する最適っぽい感じの解です。
これをそのまま真似しても、お読みの方の組織に対してフィットしない可能性が多分にあることをあらかじめご了承ください。

君だけの最強の効率化の仕組みを作ろう!!(ホビーアニメ的なアレ)

現状の確認

まずは自分の置かれた現環境を確認してみます。
現場百遍は捜査の基本ですね!

  • ゲストアカウントの利用開始は、ユーザーが情シスに招待依頼を申請する「管理者が一括で管理する」方式である

  • ゲストアカウントの招待依頼はSlackワークフローから簡易的な申請を通して行われている

  • ゲストアカウント招待時には有効期限は特に設定されておらず、申請を通ったものはそのまま無期限でアカウント有効化

情シスが窓口になっているので、窓口が1本化されており「なんか知らんうちにゲストがポコポコ増えちゃった!」という状態は抑えられております。
他方で、招待するまではよいのですが、招待したあとほぼ誰も実態を把握してない、ということになるのが最大の問題点となります。
招待者が情シスに固定されてしまうため、仮に招待者にリマインドを飛ばしても本人と情シスだけに飛ぶ、ということになります。
(そして情シスは事業側の経緯までは詳しく知ることはない=ゲスト要るのかわからん状態の発生

依頼の履歴はSlackワークフローのみ、過去の依頼状況を探るにはログを検索するほかなく、パッと分かる情報が一つもありません。
実際問題、ゲストアカウントを棚卸しする際に「このゲストが要るのか?要らないのか?という点が誰にも分からない」という状況が表面化していました。

「招待した人」がいるんだから分かりそうなものでは?と思うのですが、分からないもんなんですよね。
人間なんて昨日のお昼ごはんすら覚えてないのに、だいぶ昔に招待依頼したアカウント(しかも自分が招待依頼した以外の場合もある)なんて覚えていようはずもなく。

実際に「ゲストアカウントを棚卸しせよ」というミッションが課せられた際には、

  1. ゲストアカウントを吐き出すGASで全部リストアップ

  2. 関係者(と思しき人達)を集めて人海戦術で確認

  3. 明示的に「要る」と回答されたアカウントを残し、アカウント解除

という、完全人頼みな方法で確認してました。

賢明な諸氏なら勘づいておられると思うのですが、「だれに聞いても回答なし、じゃあ無効化しようか!」と無効化したら「ゲストから入れないって言われたのですが…」というお問い合わせが、まぁ出てきましたよね…。
oh………🫠

忘却を防ぐための仕組みを考える

こうした「ゲストアカウント、いったい誰が把握してるの問題」を、ある程度追跡・管理できるような要件として、以下のものを整理しました。

  • ゲスト招待依頼時に、ゲスト招待依頼者と、依頼を承認した人(マネージャー)を依頼履歴(スプシ一覧表)に記録する

  • ゲスト招待依頼時には依頼元部署名を入力必須とし、これまた依頼履歴に記録する

  • ゲストには有効期限を設定し、期限切れ前にリマインド=一定サイクルの棚卸しをする

  • 確認は依頼元部署のマネージャークラスのメンバーに対して行う

定期的なサイクルで確認することによりある程度の忘却は軽減し、依頼元部署も記録しておいて当人が不在となっても最低限部署に確認すればヒントは得られるであろう、という点を盛り込んであります。

また、「ゲストのIDが無関係な人の目に触れる確率を下げたい」という情報保護面での要望もあったため、マネージャークラスの限られた人間にのみ棚卸確認を取る、という方向になりました。

仕組みの実装を考える

依頼時の仕組み

幸いにしてゲスト招待は「情シスへ依頼し、情シスが管理者として招待する」という既存のフローが確立しているため、ゲスト招待の入口は一箇所に集約できている状態です。

依頼はSlackワークフローを通して行われるため、稼働中の依頼ワークフローを改修し、以下のようなステップにしました。

  1. 依頼者による申請(各種依頼事項記入)

  2. 依頼者のマネージャー(上司)による承認

  3. 情シスへ依頼通知

  4. 依頼履歴スプシに転記

  5. 情シスによる招待処理

  6. 依頼元へ招待完了通知

これで

  • 誰が依頼したか

  • どの部署が依頼したのか

  • 依頼者情報

  • 依頼者上司への通知先

の情報を取得、記録、一覧化できるようになりました。

棚卸しの運用を考える

情報は取れるようになったので、あとは棚卸しサイクルを考えていきます。
正直、こまめにリマインドしすぎても逆効果かな…と思ったので、以下の運用ルールにしました。

  • ゲスト有効期限の設定は三ヶ月+α

    • 招待依頼日から起算して、3ヶ月後の末日に統一

  • 有効期限切れの月の15日に依頼者のマネージャー当てにリマインド通知

  • 延長処理は月の25日に一括して実行

確認サイクルは長すぎず短すぎずの「3ヶ月くらい」にしてあります。
(また、長期スパンでワークスペースに参加している外部委託業者さんも四半期サイクルで契約更新しているケースが多かったので、そちらにも合わせる形となりました)

有効期限の設定条件を統一することにより、リマインドのタイミング有効期限再設定のタイミングを合わせることが可能になりました。

スクリプトで実装していく

要件は固まったので仕組みを作っていきます。
ここではGAS(Google App Script)で以下の処理を作っていきます。

  • アクティブなゲストアカウントをリストアップする処理

  • 有効期限間近なゲストアカウントに対してリマインドする処理

以下の記事をものすごく参考にさせていただきました。

先人の智慧に感謝…!圧倒的感謝…!!

事前準備

Slack Appの作成とIncommingWebhookURLの取得は上記記事内を参考に行っていただければと思います。よって省略ッ!!!(手抜き)
取得したトークンやウェブフックURLなどもGASのスクリプトプロパティにあらかじめ格納しておきます。

それと、

  • ゲストアカウントをリストアップするスプシ

  • ユーザーがゲストが必要かどうかチェックするためのスプシとドライブ

を作っておきます。

ゲストアカウントをリストアップするスプシはGASでゲストをリストアップするのに用います。
システムによる自動処理用ですね。

チェック用のスプシは、ユーザーがゲストアカウントを棚卸する用途として別のスプシにし、ユーザーも編集可能な領域に保存します。
必要なユーザーだけ、スプシを格納した共有ドライブに都度自動追加するようにしています。

チェックシートのテンプレはこんな感じ

コードを作成する

という感じの前提のもと、作成したのが以下のようなコードになりました。

config.gs (定数設定用)
他のスクリプトで使用する定数などを設定

// マスタ用スプシID
const sheetID = PropertiesService.getScriptProperties().getProperty("SHEET_ID");
const ss = SpreadsheetApp.openById(sheetID);
const resultSheet = ss.getSheets()[0];
const inviteSheetName = 'InviteRequests';
const templateSheetName = 'Template';
const inviteSheet = ss.getSheetByName(inviteSheetName);

// ユーザーチェック用スプシIDとドライブID
const checkSheetID = PropertiesService.getScriptProperties().getProperty("CHECK_SHEET_ID");
const checkDriveID = PropertiesService.getScriptProperties().getProperty("CHECK_DRIVE_ID");
// Slackトークン(SlackApp)
const botToken = PropertiesService.getScriptProperties().getProperty("SLACK_BOT_TOKEN");
const userToken = PropertiesService.getScriptProperties().getProperty("SLACK_USER_TOKEN");

// 通知チャンネルのIncommingWebhookURL
const webhookURL = PropertiesService.getScriptProperties().getProperty("WEBHOOK_URL");

// 通知チャンネル(fn_slack_guest_extend)
const extendChannelId = PropertiesService.getScriptProperties().getProperty("EXTEND_CHANNEL_ID");

// リマインド日付n日前設定
const beforeLimit = 31;

main.gs(ゲストアカウントリストアップ)
アクティブなゲストアカウントをスプシにリストアップする処理群

/**
 * Slackゲストアカウントリストアップ処理
 * ゲストユーザーをスプレッドシートにリストアップする
 * 定期実行:実行サイクル毎日0時
 */
function updateList() {
  // ゲスト情報を取得
  const guestInfo = getSlackGuestUsersInfo();

  // スプシにユーザー情報をセットする
  const headers = ['User ID', 'Display Name', 'Real Name', 'Email', 'Guest Type', 'Channels', 'Deactive Date']; 
  resultSheet.clear();  
  
  resultSheet.appendRow(headers);  
  resultSheet.getRange(2, 1, guestInfo.length, guestInfo[0].length).setValues(guestInfo);
}

/**
 * ゲスト情報を取得
 */
function getSlackGuestUsersInfo() {
  // ユーザー情報を取得する
  const userList = getUserList(); //ユーザーリストをアレイ変数内に格納

  // 変数の初期化
  let guestUsersInfo = [];
  let ultraRestricted = "";
  let deactiveLimit = "";

  for(i=0; i<userList.length; i++){
    for(let user of userList[i]) {
      // 削除済み、ゲストユーザー以外はスルー
      if(user.deleted === true || user.is_restricted === false) {
        ; //削除済みorメンバーなのでなにもしない
      } else{
        // ゲスト招待がシングルかマルチか
        if (user.is_ultra_restricted === true) {
          ultraRestricted = 'シングル';
        } else {
          ultraRestricted = 'マルチ';
        }

        // ゲストの有効期限を格納、なければ空白
        if (user.profile.guest_expiration_ts === undefined) {
          deactiveLimit = '';
        } else {
          deactiveLimit = tsToJSTClock(user.profile.guest_expiration_ts); //JST変換処理
        }

        // ユーザー所属チャンネル取得
        let userChannels = getUsersChannels(user.id).toString();

        // ゲストユーザーオブジェクトに格納
        guestUsersInfo.push([
          user.id,                    //ID
          user.profile.display_name,  //ユーザー表示名
          user.real_name,             //氏名
          user.profile.email,         //Eメール
          ultraRestricted,            //ゲスト種別
          userChannels,               //所属チャンネル
          deactiveLimit,              //有効期限
        ]);
      };
    };
  };
  return guestUsersInfo;
}

/**
 * ユーザー情報を取得
 */
function getUserList() { 
  // 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",
      "muteHttpExceptions": true,
      "payload": { 
        "token" : userToken,
        "cursor": cursor,
        "limit" : limit
      }
    };
  
    // ゲスト取得APIリクエスト
    const usersListUrl = "https://slack.com/api/users.list";
    let 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;
}

post.gs(リマインド通知)
有効期限が近いアカウントを拾って通知する処理群

/**
 * 有効期限ユーザーを取得して通知
 * updateListで実行取得されたゲスト、inviteRequestに記録されている依頼情報をマッチしてユーザーに通知する
 * 定期実行:1ヶ月に1回
 */
function notifyGuestUsers () {
  // 期限間近のゲストユーザー情報と招待者情報を取得
  const notificationUsers = getTargetUsers();

  // 確認用チェックリストをコピー作成、貼り付け
  const sheetInfo = copyTemplateSheet();
  pasteGuestData(sheetInfo, notificationUsers);

  // 確認依頼のリマインドをチャンネルへ通知
  notifyToMembers(notificationUsers, sheetInfo[1]);
}

/**
 * 有効期限間近のユーザーを変数へ格納
 */
function getTargetUsers () {
  const startRow = 2; //データ開始行
  const startCol = 1; //データ開始列
  let lastRow = resultSheet.getLastRow(); //データが入ってる最終行
  let lastCol = resultSheet.getLastColumn(); //データが入ってる最終列
  
  // アクティブゲストシートのデータを全取得
  let guestArray = resultSheet.getRange(startRow,startCol,lastRow,lastCol).getValues();

  // 取得したゲスト情報から有効期限間近のものをフィルタリング
  // ここでは試しに有効期限が入っているかどうかだけ判定
  let expirationGuests = guestArray.filter(function(exp){
    if (exp[6] !== '') {
      // 今月末に期限切れになるアカウントのチェック
      return checkExpiryDate(exp[6]) <= (beforeLimit);
    }
  });
  Logger.log(expirationGuests);

  // 依頼リストシートのデータを全取得
  lastRow = inviteSheet.getLastRow(); //データが入ってる最終行
  lastCol = inviteSheet.getLastColumn(); //データが入ってる最終列
  let invateArray = inviteSheet.getRange(startRow,startCol,lastRow,lastCol).getValues();

  // 期限間近のユーザーと招待依頼情報をマッチングし、マッチしたら情報格納
  let guestInventory = []; // アクティブゲスト情報格納用配列
  for (i=0; i<expirationGuests.length; i++) {
    // わかりやすいように変数にアイテムを格納
    const guestEmail = expirationGuests[i][6]; // ゲストEmail
    const guestID = expirationGuests[i][0]; // ゲストSlackID
    const guestDispName = expirationGuests[i][1] // ゲスト表示名
    const guestType = expirationGuests[i][4] // ゲストタイプ
    const guestChannel = expirationGuests[i][5] // ゲスト参加チャンネル
    const guestExpiry = expirationGuests[i][6] // ゲスト有効期限

    for (j=0; j<invateArray.length; j++) {
      let requestEmail = invateArray[j][7];
      // リクエスト文字列にゲストのEmailアドレスが含まれていたら棚卸し情報を配列に格納
      if (expirationGuests[i].indexOf(requestEmail) !== -1) {
        // わかりやすいように変数にアイテムを格納
        const requestDiv = invateArray[j][5]; // 依頼元部署名
        const requestUser = invateArray[j][3]; // 依頼ユーザー
        const requestMgr = invateArray[j][4]; // 依頼上司

        // Array 0:ID 1:表示名 2:ゲストタイプ 3:チャンネル 4:期限 5:部署 6:確認者1 7:確認者2
        let inventory = []; // 招待情報格納用配列
        inventory = [guestID, guestDispName, guestType, guestChannel, guestExpiry, requestDiv, requestUser, requestMgr];
        guestInventory.push(inventory);
        break;
      }
    }
  }

  return guestInventory; // ゲスト情報アレイを返す
}

/**
 * ユーザー情報をチェックシートに貼り付け
 */
function pasteGuestData (sheetInfo, guestInfo) {
  // シート情報
  const cs = SpreadsheetApp.openById(checkSheetID);
  const checkSheet = cs.getSheetByName(sheetInfo[0]);
  // 開始位置
  const startRow = 4; //データ開始行
  const startCol = 2; //データ開始列
  // シートに書き込み
  checkSheet.getRange(startRow, startCol, guestInfo.length, guestInfo[0].length).setValues(guestInfo); 
}

/**
 * Slackの所定のチャンネルに投稿する
 */
function notifyToMembers (targetUsers, activesheetId) {
  // ユーザー不明フラグ
  let undefinedUsers = false;
  // チェックシートURL
  const checkSheetURL = "https://docs.google.com/spreadsheets/d/" + checkSheetID + "/edit?gid=" + activesheetId;

  if (targetUsers.length < 1) {
    const prebody =
    {
      "blocks": [
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "Slackのゲストユーザーをチェックしました!\n" + 
            "どうやら直近で有効期限が近いゲストさんはいらっしゃらないようです!:yui_doya:",
          }
        }
      ]
    }
  } else {
    // 通知文Part1:共通メッセージ部分作成
    const prebody =
    {
      "blocks": [
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "<!channel>\n" + 
                    "Slackのゲストユーザーの有効期限が切れそうです!:yui_laugh:",
          }
        },
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "確認リストを開いて、ゲスト有効期限延長の有無を回答してください!\n" +
                    "回答締め切りは *現時点から1週間後* です!それまでに回答がなかったものは継続不要と判断します!"
          },
          "accessory": {
            "type": "button",
            "text": {
              "type": "plain_text",
              "text": "確認リスト",
              "emoji": true
            },
            "value": "click_me",
            "url": checkSheetURL,
            "action_id": "button-action"
          }
        },
        {
          "type": "divider"
        }
      ]
    }
    // Slackへの投稿処理
    postSlack(prebody, webhookURL);

    // 通知分Part2:期限間近ゲストユーザー情報
    for (let guests of targetUsers) {
      const guestID = guests[0]; 
      const guestExpiry = guests[4];
      const requestDiv = guests[5];
      let requestUserEmail = guests[6];
      let requestMgrEmail = guests[7];

      // 依頼者のSlackユーザー名変換
      let requestUser = findUserByEmails(requestUserEmail);
      if (requestUser.indexOf("undefined") === -1){
        requestUser = "<@" + requestUser + ">";
      } else {
        undefinedUsers = true;
      }
      
      // 依頼者上司Slackユーザー名変換
      let requestMgr = findUserByEmails(requestMgrEmail);
      if (requestMgr.indexOf("undefined") === -1){
        // 依頼元マネージャーをゲストアカウント定期棚卸 ドライブへ招待
        addDriveUsers(requestMgrEmail);
        // 依頼元マネージャーをfn_guest_extendチャンネルへ招待
        inviteChannel(requestMgr);
        requestMgr = "<@" + requestMgr + ">";
      } else {
        undefinedUsers = true;
      }

      const mainbody =
      {
        "blocks": [
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "`ゲストユーザー`\n" +
                "<@" + guestID + ">"
              },
              {
                "type": "mrkdwn",
                "text": "`有効期限`\n" +
                Utilities.formatDate(guestExpiry,"JST", "yyyy/MM/dd")
              },
              {
                "type": "mrkdwn",
                "text": "`ゲスト招待依頼元部署`\n" +
                requestDiv
              },
              {
                "type": "mrkdwn",
                "text": "*`招待依頼者`*\n" + 
                "*main:* " + requestUser + "\n" +
                "*mgr :* " + requestMgr
              }
            ]
          },
          {
            "type": "divider"
          }
        ]
      }
      // Slackへの投稿処理
      postSlack(mainbody, webhookURL);
    }
  }

  // 識別不能ユーザーが存在した場合情シスへメンション
  if (undefinedUsers = true) {
    const infoBody =
    {
      "blocks": [
        {
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": "<!subteam^XXXXXXXX> notification! \n" + 
            "SlackユーザーIDが存在しないメンバーを検知しました。\n" +
            "1.依頼元部署へのゲスト継続の確認、2.依頼情報リストの訂正 を実行してください。",
          }
        }
      ]
    }
  }
  Logger.log("post done");
}

sub.gs(サブプロシージャ的な小さめのファンクション)
サブ処理をまとめて置いてある場所

/**
 * サブファンクション集
 */

/**
 * ユーザー所属パブリックチャンネルを取得
 * ざっくりわかればよいので取得上限20件リミット
 */
function getUsersChannels(userId) {
    // チャンネル取得(上限は20件)
    const options = {
        "method": "get",
        "contentType": "application/x-www-form-urlencoded",
        "muteHttpExceptions": true,
        "payload": {
            "token": userToken,
            "user": userId,
            "type": "public_channel,private_channel",
            "limit": 20
        }
    };
    const usersListUrl = "https://slack.com/api/users.conversations";
    let response = UrlFetchApp.fetch(usersListUrl, options);

    // レートリミットが超過した場合は1分スリープして待つ、待ったらおしまい
    if (response.getResponseCode() === 429) {
        Utilities.sleep(60000);
    }

    const json = JSON.parse(response.getContentText()).channels;

    let channelName = "";
    let joined = [];

    for (let i in json) {
        channelName = json[i].name;
        joined.push(channelName);
    };

    return joined;
}

/**
 * 有効期限のタイムスタンプを日本時間表記に変更
 */
function tsToJSTClock(ts) {
    const jstTime = Utilities.formatDate(new Date(ts * 1000), "JST", "yyyy/MM/dd HH:mm:ss");
    return jstTime;
}

/**
 * 有効期限の残日数を出力
 */
function checkExpiryDate(guestExpiryDate) {
    const expiryDate = new Date(guestExpiryDate);
    const now = new Date();
    const termDay = (expiryDate - now) / 86400000;
    return termDay;
}

/**
 * Templateシートをコピーし、書き込み用シート新規作成
 */
function copyTemplateSheet() {
    // テンプレシートの情報
    const spreadsheet = SpreadsheetApp.openById(sheetID);
    const formatSheet = spreadsheet.getSheetByName(templateSheetName);
    const toSheet = SpreadsheetApp.openById(checkSheetID);

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

    // テンプレシートをコピー、シート名を変更する
    const taskSheet = formatSheet.copyTo(toSheet).setName(sheetDate);
    taskSheet.activate();
    toSheet.moveActiveSheet(1);

    // アレイに値を格納
    let sheetInfo = [];
    sheetInfo.push(sheetDate);
    sheetInfo.push(taskSheet.getSheetId());

    // シート名、シートIDの入ったアレイを返り値にセット
    return sheetInfo;
}

/**
 * EmalアドレスからSlackユーザーIDを取得
 */
function findUserByEmails(email) {
    // APIエンドポイント
    const url = "https://slack.com/api/users.lookupByEmail"
    const payload = {
        "token": userToken,
        "email": email
    }
    const options = {
        "method": "GET",
        "payload": payload,
        "headers": {
            "contentType": "x-www-form-urlencoded",
        }
    }

    let jsonData = UrlFetchApp.fetch(url, options); // APIリクエスト
    jsonData = JSON.parse(jsonData) // JSONデコード

    let userId;
    if (jsonData["ok"] === true && jsonData["user"]["deleted"] === false) {
        userId = String(jsonData["user"]["id"]) // true:ユーザーIDを抽出
    } else {
        userId = "undefined / " + email // false: undefine + Emailを返す
    }

    return userId
}

/**
 * SlackチャンネルへPOST処理
 */
function postSlack(body, postUrl) {
    // 送信オプション
    const options = {
        method: 'post',
        contentType: 'application/json',
        muteHttpExceptions: true,
        payload: JSON.stringify(body),
    };

    // 送信処理
    const response = UrlFetchApp.fetch(postUrl, options);
    // レートリミットを超えたら1分待ってリトライ
    if (response.getResponseCode() === 429) {
        Utilities.sleep(60000);
        return postSlack(body, postUrl);
    }
}

/**
 * Slackチャンネルへユーザーを招待
 */
function inviteChannel(userId) {
    // APIエンドポイント
    const endpoint = "https://slack.com/api/conversations.invite";

    // 招待用ペイロードとオプション
    let payload = {
        'token': botToken, // 招待アナウンスをBotで行いたいときはBOTトークン
        'channel': extendChannelId,
        'users': userId
    };
    let options = {
        'method': 'post',
        'payload': payload
    };

    // 招待処理
    let response = UrlFetchApp.fetch(endpoint, options);
    // レートリミット超えたら1分待ってリトライ
    if (response.getResponseCode() === 429) {
        Utilities.sleep(60000);
        return inviteChannel(userId);
    }
}

/**
 * Google 共有ドライブにユーザーを追加
 */
function addDriveUsers(userEmail) {
    const reqBody = {
        "role": "writer",  // 投稿者権限(ファイル編集のみ) organizer, fileOrganizer, writer, reader
        "type": "user",    // アカウントタイプ user, group, domain, anyone
        "emailAddress": userEmail  // Email address
    };
    const queryParam = {
        "sendNotificationEmails": false,
        "supportsAllDrives": true
    };
    try {
        // ドライブ招待(サイレント、ユーザー毎に編集権限で招待)
        Drive.Permissions.create(reqBody, checkDriveID, queryParam);
    } catch (err) {
        Logger.log(err.message);
    }
}

要件を考慮したうえでの実装ポイントとして、以下のような点があります。

  • 個人識別可能なIDが極力オープンにならないよう、プライベートチャンネルで確認を行う

    • 必要な人はその都度BOTが確認チャンネルへ自動招待する

  • チェックリストを置くドライブもアクセスを絞る

    • やっぱり必要な人だけBOTが自動招待するようにする

また、Slackの通知メッセージにはBlock形式を使用しています。
Block形式はメッセージの見た目をいい感じにアプリっぽくしてくれる機能で、SlackがGUIで見た目確認できるビルダーも用意してくれてます。

これで通知メッセージの見た目がグッと「アプリのやつだ!」って感じになりますね!

Slack Appは情シスマスコットキャラさん

どうも今(2024年10月時点)は、メッセージの装飾方法はBlock-Kitで提供されている方式しかサポートされていないようですね。

これまでよく利用されていた"attachments"などの記述はJSON構文エラーとして返されてしまい、Slackでは認識してくれなくなりました。
Block-Kitビルダーで生成したJSONをコピペして記述するほうが、確実にSlackへメッセージを送信できる方法になります。

運用の流れ

これで定期的にユーザーを棚卸しして、リマインドを関係者に投げるという自動処理が組めました。
ただ、以下の点は手動による処理が必要となります。

  • ユーザーによるゲストアカウントの要否判断

  • 情シスによる有効期限の延長

ここは仕組み上どうしても自動化できなかったので、毎月のルーティンワークとして組み込むことになりました。
(ゲストの有効期限をAPIでどうにかできるのはEnterpriseGridのみ)

  1. 毎月15日に有効期限間近のゲストがリマインドされる

  2. リマインドを受けた人は1週間以内に要否を回答

  3. 毎月25日に回答結果を受けて情シスが延長期限を再設定

  4. 期限切れのユーザーは自動解除

  5. 以降繰り返し

定期タスクも忘れないようにカレンダーに入れたり、繰り返しタスクにいれると良いですね。

え、既存ゲストの有効期限設定はどうしたのかって?
マンパワーです!!!!!!(設定したときは手がつるかと思った)

このときばかりはEnterpriseGridほしいと切実に思いました…。

効果

フル人力でゲストアカウントの棚卸しを行っていたときは、1ヶ月間リマインドを流し続けても要否が不明なゲストが残留し続けていました。
完全に棚卸し完了するまでに1.5ヶ月位の期間がかかっているような状況でした。

その間常に張り付きっぱなしというわけでもないのですが、奥歯に物が挟まったような状態で1.5ヶ月過ごしてました。

これが各月に分散し、リマインドもBOTが勝手に行ってくれます。
人は基本的にGUIポチポチするだけで済むようになりました。

作業的な負荷もそうなんですが、精神的な負荷のほうがだいぶ軽くなったのはとても大きいです。
棚卸しとか、大抵の人にとっては虚無の謎作業になりがちなので…。

おわりに&蛇足

な、長かった…。
運用の一例までを含めると、ゲストアカウントを管理するだけでもだいぶ考えたりやらなきゃいけないことがありますね。
アカウントの管理って一口に言っても、ライフサイクルを考えるとやることが多いです。

そして、そもそもな話をしちゃうと…。
ゲストを承認制で管理するなら、きちんとしたワークフローシステムに承認依頼を乗せて、承認ルートを経て承認させて履歴取ったほうがいいんではないかな、と思ってます。
(上記の仕組みは、スプシが壊れたり改変されちゃったら処理止まっちゃう弱点を抱えていたりしますし)

今回はそうもいかない事情があったりして、Slackワークフローとスプシこねこねしたほうがはるかに早そう&効果が出そうだったので、そうしました。
長期的に見ると、この仕組みもどこかで見直したほうが良いんだろうな、と思います。
このへんのバランス感覚は難しいですし、正解なんかひとつも無いんですよね。

作っちゃって満足ではなく、いずれもっと良いものや効率的な方法を採用してすげ替えてやろうくらいの感覚でいたいな、と思いました。


それはそれとして、Block Kitで無駄にメッセージを作り込んだりしてる時ってめっちゃ楽しいですね。
仕事に疲れたら、こういうのをこねこねする仕事をするのもまた大事なんだなぁと思いました。

いいなと思ったら応援しよう!