🐴

Slack × GAS:Slackチャンネルとメンバー情報を取得

2023/07/17に公開

どうもbarusuです。
某所でニーズがあったので、手癖に任せて書いてみました。

概要

  • Google Apps Script(GAS)とSlack APIを使用して、Slackワークスペースのすべてのチャンネルとそのメンバー情報を取得し、それをGoogleスプレッドシートに書き込むスクリプトです。
  • 取得するチャンネル数が多いと何回かに分けて一覧を取得しなきゃいけない(=ページネーションとかって言います)ので、Slackがっ!cursorをっ!空にするまでっ!API叩くのをやめないっ! な処理にしてます。
  • APIのレートリミットに抵触してエラーになっちゃうので、そこらへんもよしなにやります。

事前準備

SlackBotの追加

チャンネル情報とメンバー情報を取得するためのAPIを呼び出しにはBotのTokenが必要なので、SlackワークスペースにBotを追加します。
Botの作成と設定について:https://api.slack.com/start

SlackBotへの権限追加

このコードの実行に必要な権限は以下。

  • channels:read - チャンネル情報を読み取る
  • users:read - ユーザー情報を読み取る
  • users:read.email - ユーザーの電子メールアドレスを読み取る
    参考:https://api.slack.com/start/quickstart

これらの権限が与えられていることを確認した上で、トークンを適切に保管し、第三者に知られないようにしましょー。

GASの準備

GASのエディタで新しいプロジェクトを作成し、スクリプトをペーストします。
参考:https://developers.google.com/apps-script/guides/sheets

環境変数の設定

GASのプロジェクトプロパティで環境変数を設定します。
プロパティ名は以下の名前で追加、値にはそれぞれSlackBotのトークンと出力先のスプレッドシートIDを入力しときます。

コード

とりあえずコードだけ欲しいって方向けの全文。
スプレッドシートに'Channels'と'Members'シートが必要。

function writeToSpreadsheet() {
  const token = PropertiesService.getScriptProperties().getProperty('SLACK_BOT_TOKEN');
  const spreadsheetId = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');

  // Channel List
  const channelListSheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName('Channel List');
  channelListSheet.clear();
  channelListSheet.appendRow(['Channel ID', 'Channel Name', 'Is Archived', 'Member Count']);
  fetchSlackData('https://slack.com/api/conversations.list', token, channelListSheet, writeChannelList);

  // Channel Members
  const channelMemberSheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName('Channel Members');
  channelMemberSheet.clear();
  channelMemberSheet.appendRow(['Channel ID', 'Channel Name', 'Member ID', 'Username', 'Email', 'Real Name', 'Type', 'Is Bot']);
  fetchSlackData('https://slack.com/api/conversations.list', token, channelMemberSheet, writeChannelMembers);
}

function fetchSlackData(url, token, sheet, callback) {
  let params = {
    token: token,
    limit: 200
  };

  do {
    const options = {
      method: 'GET',
      headers: { 'Authorization': `Bearer ${token}` },
      muteHttpExceptions: true
    };
    options.payload = params;

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

    if (response.getResponseCode() === 429) {
      Utilities.sleep(60000);
      continue;
    }

    let result = JSON.parse(response.getContentText());
    if (!result.ok) {
      Logger.log(result.error);
      return;
    }

    callback(result, sheet, token);
    params.cursor = result.response_metadata.next_cursor;

  } while (params.cursor !== '');
}

function writeChannelList(result, sheet, token) {
  let dataToAppend = [];
  result.channels.forEach(channel => {
    dataToAppend.push([channel.id, channel.name, channel.is_archived, channel.num_members]);
  });
  sheet.getRange(sheet.getLastRow() + 1, 1, dataToAppend.length, dataToAppend[0].length).setValues(dataToAppend);
}

function writeChannelMembers(result, sheet, token) {
  result.channels.forEach(channel => {
    let channelId = channel.id;
    let channelName = channel.name;
    let members = channel.members;

    let dataToAppend = [];
    members.forEach(memberId => {
      const userInfo = fetchUserInfo(memberId, token);
      dataToAppend.push([channelId, channelName, memberId, userInfo.name, userInfo.email, userInfo.real_name, userInfo.type, userInfo.is_bot]);
    });
    sheet.getRange(sheet.getLastRow() + 1, 1, dataToAppend.length, dataToAppend[0].length).setValues(dataToAppend);
  });
}

function fetchUserInfo(userId, token) {
  let url = 'https://slack.com/api/users.info';
  let options = {
    method: 'GET',
    headers: { 'Authorization': `Bearer ${token}` },
    muteHttpExceptions: true
  };
  options.payload = {
    token: token,
    user: userId
  };

  let response = UrlFetchApp.fetch(url, options);
  if (response.getResponseCode() === 429) {
    Utilities.sleep(60000);
    return fetchUserInfo(userId, token);
  }

  let user = JSON.parse(response.getContentText()).user;
  let type = getUserType(user);
  return {
    name: user.name,
    email: user.profile.email,
    real_name: user.real_name,
    type: type,
    is_bot: user.is_bot
  };
}

function getUserType(user) {
  if (user.is_owner) return 'Owner';
  if (user.is_admin) return 'Admin';
  if (user.is_restricted) return 'Multi-Channel Guest';
  if (user.is_ultra_restricted) return 'Single-Channel Guest';
  if (user.is_bot) return 'Bot';
  return 'Member';
}

(蛇足)解説

環境変数読み込み

const token = PropertiesService.getScriptProperties().getProperty('SLACK_BOT_TOKEN');
const spreadsheetId = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');

スクリプトのプロジェクトプロパティから取得してます。
重要な情報をコード内に直接書かずに済むので、まあほぼ必須ですね。
たまに横着して省略しちゃうけど...ヨクナイ...

HTTPリクエスト送信

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

URLFetchAppを使って、Slack APIへリクエストを送ります。
このリクエストは指定したURLに対して行われ、オプションにはHTTPメソッド(この場合はGET)やヘッダー情報(Botトークン)が含まれます。
今回はGETなのでオプションやらはなしでURLパラメータをちょちょっと付けてリクエストしてます。
GAS書いてるとまじでよく使うので辞書登録しても良いくらい。

Do-Whileループ

  do {
    ...
    params.cursor = result.response_metadata.next_cursor;
  } while (params.cursor !== '');

このDo-Whileループは、チャンネルやメンバーの情報を一度に全て取得できない場合(チャンネルやメンバーの数が多すぎる場合)に必要です。
Slack APIはページネーションをサポートしていて、一覧取得APIで次のページがある場合はnext_cursorにトークン文字列が入ってます。
このnext_cursorを使って再度リクエストを送信すると、次のページが取得できます。
ので、next_cursorが空になるまでDo-Whileでループしてます。
このループ内にチャンネル取得とか諸々の処理を入れればOKって寸法です。

users.info

const user = JSON.parse(response.getContentText()).user;
const type = getUserType(user);

各メンバーについて、users.info APIを使用して詳細情報を取得してます。
その後、getUserType関数を使用してメンバーの種別(オーナー、管理者、一般メンバーなど)を選別してわかりやすくしてます。

取得した各メンバーの情報を配列に追加


let dataToAppend = [];
for (let i = 0; i < members.length; i++) {
  const memberId = members[i];
  ...
  dataToAppend.push([channelId, channelName, memberId, username, email, realName, type, isBot]);
}
sheet.getRange(sheet.getLastRow()+1, 1, dataToAppend.length, dataToAppend[0].length).setValues(dataToAppend);

getRangeとsetValuesメソッドを使って、配列内の全てのデータを一度にスプレッドシートに追加してます。
スプレッドシートAPIをループごとに叩くのはAPIリクエスト数が多くなって処理が重くなるし、API Limitに抵触しやすくなってすぐエラーで止まるようになるので、まとめてシート追加の処理にしておかないとまともに使えません。

APIレートリミット超過対策

const fetchWithRateLimit = (url) => {
  while (true) {
    let response = UrlFetchApp.fetch(url, {muteHttpExceptions: true});
    let code = response.getResponseCode();
    if (code == 200) {
      return response;
    } else if (code == 429) { // API rate limit reached
      Utilities.sleep(60000); // Wait for 1 minute
    } else {
      console.log('Failed to fetch the URL: ' + url);
      console.log('Response Code: ' + code);
      console.log('Response Body: ' + response.getContentText());
      return null;
    }
  }
}

UrlFetchApp.fetch()を用いてAPIからデータを取得しようとした際、レスポンスコードが429(=レートリミットを超えている)であれば、60秒間(60000ミリ秒)待機します。

Slackもそうですけど、一般的なAPIのレートリミットはだいたい1分でリセットされるのが多いです。
1分間待機後、同じリクエストを再度行い、これを最大5回繰り返します(とりあえず5回)。
一旦待つ処理を入れることで、処理回数が嵩んで一時的なレートリミットを超過してもデータ取得を続行できます
適切なエラーハンドリングとデータ取得のロバスト性を保つためって感じの処理です。

おわりに

GASのトリガーを設定しておけば任意の周期で一覧の自動更新ができるので、好きにやってみてください。
意外と俺の記事って読まれてるみたいなんで、初学者向けにも適宜解説入れてみました。
疑問点や質問があればコメントおなしゃす。反応するかはわからんですけど。

以上、あざっした

Discussion