弁護士ドットコム株式会社 Creators’ blog

弁護士ドットコムがエンジニア・デザイナーのサービス開発事例やデザイン活動を発信する公式ブログです。

GAS+SlackAPIで新着チャンネル検知を作ってみた

この記事は、弁護士ドットコム Advent Calendar 2024 の14日目の記事です。
クラウドサインのフロントエンドエンジニアとして入社して5年の @happylifetaka です。

免責:本記事に記載しているコードはアドベントカレンダー用に作成したものであり、実際に使用しているコードとは異なります。使用する際は、必要に応じて修正していただいたうえで、自己責任でご利用ください。

概要

当社では、Slack をチャットツールとして使用しており、社員が自由にチャンネルを作成し、ジャンルごとの雑談や日報を共有しています。
しかし、新しいチャンネルが作られたことを社内に広く知らせたい場合、作成者が自ら通知しないと、他の社員は新しいチャンネルの存在に気づけないという問題がありました。
この問題を解決するために、Slack の API と Google Apps Script (以下、GAS) を使った通知システムを導入しました。
このシステムは、SlackのEvent API を使ってチャンネルが作成された瞬間を検知し、その情報を GAS 経由で特定のチャンネルに投稿します。
導入後の小話ですが、新しく入社された社員が分報を作成した時に、あまりにもすぐに既存社員が参加するので驚かれることがたびたびあります。

実装手順

  1. Slack App の作成:
    Slack API のサイトで新しいアプリを作成します。
    「Create New App」→「From scratch」、App Name は適当な名前を入れ、導入する Workspace を選択し「Create App」を選択します。
    Slack App の作成1
    Slack App の作成2

  2. 権限の設定 「OAuth & Permissions」タブで「Chat:Write」と「channels:read」のスコープを追加します。
    権限の設定

  3. アプリのインストール:
    「Install App」タブでワークスペースにアプリをインストールし、必要な権限を許可します。

  4. GAS の設定:
    GAS のプロジェクトの設定→スクリプトプロパティで「VERIFICATION_TOKEN」「BOT_TOKEN」「POST_CHANNEL_ID」を設定します。
    「VERIFICATION_TOKEN」に、Slack の「Basic Information」タブの「Verification Token」を設定します。

VERIFICATION_TOKEN

「BOT_TOKEN」に、Slack の「OAuth & Permissions」タブ「OAuth Tokens」の「Bot User OAuth Token」を設定します。

BOT_TOKEN

「POST_CHANNEL_ID」に、Slack の投稿先のチャンネル名を右クリックし「チャンネル詳細を表示する」→下部の「チャンネル ID」を設定します。
POST_CHANNEL_ID

  1. コード(以下、コードサンプルを参照)の実装とデプロイ:
    GAS にコードを実装し、デプロイを選択します。ウェブアプリを選び、アクセスできるユーザーを全員にしデプロイします。
    デプロイ

  2. イベントの設定:
    Slack の「Event Subscriptions」タブで「Enable Events」をオンにし、GAS のデプロイ URL を設定します。
    イベントを送れるか検証されます。Verified になれば OK です。
    「Subscribe to bot events」に「channel_created」を追加し「Save Changes」を選択します。
    イベントの設定

  3. チャンネルへの通知設定:
    「チャンネル詳細を表示する」→「インテグレーション」タブ、App の「アプリを追加する」で、通知先のチャンネルにアプリを追加します。
    チャンネルへの通知設定

  4. 通知の確認:
    新しいチャンネルが作成されると、設定したチャンネルに通知が投稿されることを確認します。
    新着通知の例


もし、チャンネルのリネームを検知したい場合「channel_rename」イベントを使うのが良いでしょう。

コードサンプル

function doPost(req) {
  const postData = JSON.parse(req.postData.getDataAsString());
  const VERIFICATION_TOKEN = PropertiesService.getScriptProperties().getProperty('VERIFICATION_TOKEN');
  const BOT_TOKEN = PropertiesService.getScriptProperties().getProperty('BOT_TOKEN');
  const POST_CHANNEL_ID = PropertiesService.getScriptProperties().getProperty('POST_CHANNEL_ID');

  if (!postData.type || postData.token != VERIFICATION_TOKEN){
      throw new Error("Invalid request");
  }

  switch(postData.type) {
      case 'url_verification':
          return ContentService.createTextOutput(postData.challenge);
      case 'event_callback':
      if (postData.event.type == 'channel_created') {
          const formData = {
          'token': BOT_TOKEN,
          'channel': POST_CHANNEL_ID,
          'text': '新着チャンネル #' + postData.event.channel.name + ' が作成されました',
          'link_names': true,
          };
          
          const options = {
          'method': 'POST',
          'payload': formData,
          };
          return UrlFetchApp.fetch('https://slack.com/api/chat.postMessage', options);
      }
      break;
  }
}

立ちはだかる問題からの実装

上記の方法で実装を考えていましたが、当時、社内セキュリティポリシーにより GAS の URL を外部公開できなかったため、Event API を使う方法は選択できませんでした。

解決方法として、外部アクセス可能なサーバーを用意する、該当ファイルだけ外部アクセス可能にするなどのさまざまな方法があります。
今回、諸々の状況を踏まえ検討の結果、力技となりますが、定期的に API を呼び出してチャンネル情報を取得する方法に変更しました。
ニッチな内容ですが、興味がある人はご覧ください。

新実装手順

  1. Slack App の作成: 前述と同じ手順でアプリを作成します。
  2. 権限の設定: 前述と同じです。
  3. アプリのインストール: 前述と同じです。
  4. トークンの設定: 前述とほぼ同じです。VERIFICATION_TOKEN の設定は不要です。
  5. スプレッドシートの作成: チャンネル情報を保存するスプレッドシートを作成します。
  6. GAS コードの実装: コード(以下、新コードサンプルを参照)を GAS で実装します。
  7. プロパティの設定: 前述のスクリプトプロパティを設定します。VERIFICATION_TOKEN の設定は不要です。
  8. コードの実行: 実行し動作を確認します。
  9. トリガーの設定: 定期的にスクリプトが実行されるようにトリガーを設定します。 権限の設定

Slack API の conversations.list を使用してパブリックチャンネルのリストを取得し、スプレッドシートに保存します。 スプレッドシートをチャンネル情報の保持に使い、既存のチャンネルとの比較することで、新規やリネームされたチャンネルを通知します。

新コードサンプル

// Slack API のBOTトークンを取得
const BOT_TOKEN = PropertiesService.getScriptProperties().getProperty('BOT_TOKEN');
const POST_CHANNEL_ID = PropertiesService.getScriptProperties().getProperty('POST_CHANNEL_ID');

/**
* チャンネル情報を取得して処理するメイン関数
*/
function getChannel() {
const prevChannelNameList = getPrevChannelNameList();
const prevChannelIDList = getPrevChannelIDList();
const result = [];
let cursor = undefined;
let renamed = false;

do {
    const response = getNewChannelList(cursor);

    response.channels.forEach(channel => {
        if (prevChannelNameList.includes(channel.name)) return; // 既存のチャンネル名はスキップ

        if (prevChannelIDList.includes(channel.id)) {
            renamed = true; // IDが一致するが名前が違う場合はリネーム扱い
        }

        // 新しいチャンネルまたはリネームされたチャンネルの情報を追加
        result.push({
            'name': channel.name,
            'channelId': channel.id,
            'createdDate': getJSTDate(channel.created),
            'renamed': renamed
        });
        renamed = false; // リセット
    });

    cursor = response.next;
    Utilities.sleep(100); // API呼び出し間隔を空ける
} while (cursor !== '');
    if (result.length > 0) {
        // 新しいチャンネル情報をスプレッドシートに追加
        const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
        result.forEach(row => {
            sheet.appendRow([row['name'], row['channelId'], row['createdDate'], row['renamed']]); 
        });
        
        // Slackに通知
        postResult(result);
    }
}

/**
* Slackに結果を投稿する関数
* @param {Array} result - 投稿するチャンネルの情報リスト
*/
function postResult(result) {
    if (!result || result.length === 0){
        return; // 結果が空なら処理を終了
    }

    // 各チャンネルの情報を整形
    const text = result.map(item => {
        let line = `#${item['name']} (${item['channelId']})`;
        if (item['renamed']) {
            line += `,変更日時: ${item['createdDate']}  ※チャンネル名変更`;
        }else{
            line += `,作成日時: ${item['createdDate']}`;
        }
        return line;
    }).join("\n");

    // Slackにメッセージを投稿するためのパラメータ
    const url = "https://slack.com/api/chat.postMessage";
    const param = {
        'token': BOT_TOKEN,
        'channel': POST_CHANNEL_ID,
        'text': text,
        'link_names': 1, // チャンネル名にリンクを追加
    };

    const options = {
        "method": "POST",
        "payload": param,
    };

    try {
        // Slackにメッセージを投稿
        UrlFetchApp.fetch(url, options);
    } catch (e) {
         throw new Error("Slack Post Error: ", e.message);
    }
}

/**
* Slackからパブリックチャンネルの一覧を取得する関数
* @param {string} cursor - ページネーションのためのカーソル
* @returns {Object} - チャンネルリストと次のカーソル
*/
function getNewChannelList(cursor) {
    const url = 'https://slack.com/api/conversations.list';
    const param = {
        'token': BOT_TOKEN,
        'cursor': cursor,
        'exclude_archived': true,
        'limit': 200,
        'types': 'public_channel',
    };

    const options = {
        "method": "GET",
        "payload": param,
    };

    try {
        const response = UrlFetchApp.fetch(url, options);
        const newChannelList = JSON.parse(response.getContentText());

        if (!newChannelList.ok) {
            throw new Error('Slack API Error: ', newChannelList.error || 'Unknown Error');
            return { channels: [], next: '' };
        }

        return {
            channels: newChannelList.channels,
            next: newChannelList.response_metadata ? newChannelList.response_metadata.next_cursor : '',
        };
    } catch (e) {
        throw new Error('Get ChannelList Error: ', e.message);
        return { channels: [], next: '' };
    }
}
/**
* UnixタイムをJST形式に変換
* @param {number} unixTime - Unixタイムスタンプ
* @returns {string} JST形式の日付文字列
*/
function getJSTDate(unixTime) {
    return Utilities.formatDate(new Date(unixTime * 1000), "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

/**
* スプレッドシートから過去のチャンネルリストを取得
* @param {number} columnNumber - スプレッドシートの列番号
* @returns {Array} - 取得したチャンネルリスト
*/
function getPrevChannelList(columnNumber) {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
    const lastRow = sheet.getLastRow();  // 最終行を取得

    if (lastRow <= 1) return [];  // ヘッダーしかない場合は空の配列を返す

    const dataRange = sheet.getRange(2, columnNumber, lastRow - 1, 1);
    const values = dataRange.getValues();

    return values.map(row => row[0]);  // 各行からデータを取り出し配列を返す
}

/**
* 過去に取得したチャンネル名のリストを取得
* @returns {Array} - チャンネル名のリスト
*/
function getPrevChannelNameList() {
    return getPrevChannelList(1);  // チャンネル名は1列目
}

/**
* 過去に取得したチャンネルIDのリストを取得
* @returns {Array} - チャンネルIDのリスト
*/
function getPrevChannelIDList() {
    return getPrevChannelList(2);  // チャンネルIDは2列目
}

/**
* スプレッドシートの過去のチャンネル情報をクリア
*/
function clearPrevChannelList() {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
    const lastRow = sheet.getLastRow();
    const lastColumn = sheet.getLastColumn();
    sheet.getRange(2, 1, lastRow - 1, lastColumn).clear(); // 2行目以降の内容とスタイルをクリア
}

まとめ

Slack の Event API を使うとチャンネル新着検知が簡単にできて便利になりますね。 また、力技のニッチな記事も需要あると考え公開してみました。
(Slack の Event API を使うのを強く推奨します)