LoginSignup
2
1

Slack APIで「スレッドにリアクションした/していない人を確認するスラッシュコマンド」作った

Posted at

作ったもの

Slack で「完了したらリアクションして」という連絡事項系のスレッドがあります。

連絡事項スレッド(サンプル)

現時点で誰がリアクションした/していないのかを確認したいという場合、以下の構文でスラッシュコマンドを実行します。コマンドは任意に設定可能です。

/checker {スレッドのurl}

チャンネル内のメンバーのうち、スレッドにリアクションした/していないメンバーの一覧を表示します。エフェメラルメッセージ で送信されるため、メンバーにメンションが飛ぶことはありません。

image.jpg

スラッシュコマンドの第2引数に絵文字を指定することで、その絵文字のリアクションをつけた/つけていないメンバーに絞り込むこともできます。

/checker {スレッドのurl} {:絵文字:}

image.jpg

実装内容

環境

スラッシュコマンドで Google Apps Script(以降 GAS)を呼び出して Slack API を実行しています。
GAS は clasp および TypeScript を使ってローカルでコーディングしています。

環境構築については以下の記事を参考にしてください。

ソースコード

コンパイル前の TypeScript で掲載します。
各ユーザーIDや Slack へ送信するメッセージは適宜変更してください。

ソースコード全文
main.ts
const SLACK_API_TOKEN = PropertiesService.getScriptProperties().getProperty('SLACK_API_TOKEN');
if (SLACK_API_TOKEN === null) {
  Logger.log('SLACK_API_TOKEN is null.');
}

function doPost(e: GoogleAppsScript.Events.DoPost) {
  if (SLACK_API_TOKEN === null) {
    return ContentService.createTextOutput(
      'Error: SlackのAPIトークンが設定されていません。お手数ですが、実装者(<@user_id>)へご連絡お願いします。'
    );
  }

  const paramText = e.parameter.text;
  if (!paramText) return ContentService.createTextOutput('チェックしたいスレッドのURLを引数で渡してください。');

  const { threadUrl, argStamp } = extractUrlAndStampFromParameter(paramText);
  if (argStamp === false) {
    return ContentService.createTextOutput('第1引数にスレッドのurl、第2引数にスタンプを指定してください!スタンプは指定しなくても構いません。');
  }

  const threadData = getThreadData(threadUrl);
  if (!threadData) return ContentService.createTextOutput('Error: スレッドデータの取得に失敗しました。');

  const reactedUserIds = getReactedUserIds(threadData, argStamp);
  if (!reactedUserIds) return ContentService.createTextOutput('Error: スタンプを押したユーザー一覧の取得に失敗しました。');

  const usersList = getUserListOfReactedOrNot(threadData.channelId, reactedUserIds);
  if (!usersList) return ContentService.createTextOutput('Error: ユーザー一覧の取得に失敗しました。');

  const { reactedUsers, nonReactedUsers } = usersList;
  return ContentService.createTextOutput(
    `【${argStamp || 'スタンプ'}つけた人】\n${returnLogText(reactedUsers)}\n\n【${argStamp || 'スタンプ'}つけてない人】\n${returnLogText(nonReactedUsers)}`
  );
}

function extractUrlAndStampFromParameter(parameter: string) {
  const spaceRegExp = /\s+| +/;
  if (!spaceRegExp.test(parameter)) return { threadUrl: parameter, argStamp: '' };

  const splitParameter = parameter.split(spaceRegExp);
  const threadUrl = splitParameter[0];
  const argStamp = splitParameter[1];
  return /^:(.+):$/.test(argStamp) ? { threadUrl, argStamp } : { threadUrl, argStamp: false as const };
}

function getThreadData(threadUrl: string) {
  try {
    const messages = UrlFetchApp.fetch('https://slack.com/api/search.messages', {
      headers: { Authorization: `Bearer ${SLACK_API_TOKEN}` },
      payload: {
        query: threadUrl,
      },
    });

    const messagesBody = JSON.parse(messages.getContentText());
    const channelId = messagesBody.messages.matches[0].channel.id;
    const ts = messagesBody.messages.matches[0].ts;

    return typeof channelId === 'string' && typeof ts === 'string' ? { channelId, ts } : false;
  } catch (e) {
    return false;
  }
}

function getReactedUserIds({ channelId, ts }: { channelId: string; ts: string }, argStamp: string) {
  try {
    const replies = UrlFetchApp.fetch('https://slack.com/api/conversations.replies', {
      headers: { Authorization: `Bearer ${SLACK_API_TOKEN}` },
      payload: {
        channel: channelId,
        ts,
      },
    });

    const repliesBody = JSON.parse(replies.getContentText());
    const reactions: {
      name: string;
      users: string[];
    }[] = repliesBody.messages[0].reactions;
    if (typeof reactions === 'undefined') return []; // スタンプを押したユーザーがいない場合

    // 引数にスタンプを指定したかどうか
    if (argStamp) {
      const reactionUserIds = reactions.find((reaction) => reaction.name === argStamp.replaceAll(':', ''))?.users ?? [];
      return reactionUserIds;
    } else {
      const _reactionUserIds = reactions.flatMap((reaction) => reaction.users);
      const reactionUserIds = Array.from(new Set(_reactionUserIds)); // 重複削除(いずれか1つ以上のスタンプを押しているか)

      return reactionUserIds;
    }
  } catch (e) {
    return false;
  }
}

function getUserListOfReactedOrNot(channelId: string, reactedUserIds: string[]) {
  // - あらかじめ排除しておきたいユーザーのIDを配列に格納する。(Pollyやbotなど)
  const excludeIds = ['xxx'];

  try {
    const allChannelMembers = UrlFetchApp.fetch('https://slack.com/api/conversations.members', {
      headers: { Authorization: `Bearer ${SLACK_API_TOKEN}` },
      payload: {
        channel: channelId,
      },
    });

    const channelMemberIds = JSON.parse(allChannelMembers.getContentText()).members as string[];
    const reactedUsers: string[] = [];
    const nonReactedUsers: string[] = [];
    for (const id of channelMemberIds) {
      if (excludeIds.includes(id)) continue;
      reactedUserIds.includes(id) ? reactedUsers.push(`<@${id}>`) : nonReactedUsers.push(`<@${id}>`);
    }

    return { reactedUsers, nonReactedUsers };
  } catch (e) {
    return false;
  }
}

function returnLogText(usersList: string[]) {
  return usersList.length === 0 ? 'いません!' : usersList.join('\n');
}

Slack App の導入方法

本機能のために必要なパーミッションのスコープは以下の通りです。

  • channels:history
  • channels:read
  • groups:history
  • groups:read
  • mpim:history
  • mpim:read
  • search:read

詳細な導入方法の手順は他の記事や公式にお任せしますので、ここでは簡単にまとめるのみとします。

導入方法

GAS でやること

1. コーディング

GAS の home から新規プロジェクトを作成、コーディングします。
clasp を利用している場合、ローカルのエディタでコーディングを行い clasp push でプロジェクトに反映します。

2. デプロイ

「ウェブアプリ」としてデプロイし、URLをコピーしておきます。
image.jpeg
image.jpg

GAS 側でのやることは以上です。

Slack App でやること

1. App の作成

Your Apps ページの「Create New App」からアプリを新規作成します。
「From scratch」から任意のアプリ名とインストールしたいワークスペースを選択します。

image.png

2. パーミッションの設定

「Features > OAuth & Permissions」画面の「Scopes > User Token Scopes」からパーミッションのスコープを設定します。

image.png

必要なスコープは前述の通りです。

  • channels:history
  • channels:read
  • groups:history
  • groups:read
  • mpim:history
  • mpim:read
  • search:read
3. スラッシュコマンドの作成

「Features > Slash Commands」画面の「Create New Command」からスラッシュコマンドを作成します。

スクリーンショット 2023-12-06 12.04.55.png

必要な情報を設定します。

  • 「Command」に任意のコマンドを設定。ユニークであると望ましいです。
  • 「Request URL」に GAS でデプロイしたURLを貼り付けます。

image.png

4. Slack のワークスペースにインストール

「Settings > Install App」画面の「Install to Workspace」からワークスペースにアプリをインストールします。
image.png

これで Slack 内でスラッシュコマンドを呼び出せるようになります。

コード解説

以下はコードの解説となります。ご興味のある方のみご覧ください!

0. APIトークンを GAS の環境変数に設定する

GAS の環境変数の設定手順

slack api ページの Install App 画面からAPIトークンをコピー。
image.jpg

GAS のプロジェクト設定から スクリプトプロパティ を設定。
image.png

image.jpg

PropertiesService.getScriptProperties().getProperty('SLACK_API_TOKEN');とすることでコードから環境変数へアクセスできます。

1. スラッシュコマンドの引数からスレッドのurlと絵文字を取得する

e.parameter.text から引数を取得し、第2引数の有無とそれが絵文字 :xxx: であるかどうかを確認する。

function extractUrlAndStampFromParameter(parameter: string) {
  const spaceRegExp = /\s+| +/;
  if (!spaceRegExp.test(parameter)) return { threadUrl: parameter, argStamp: '' };

  const splitParameter = parameter.split(spaceRegExp);
  const threadUrl = splitParameter[0];
  const argStamp = splitParameter[1];
  return /^:(.+):$/.test(argStamp) ? { threadUrl, argStamp } : { threadUrl, argStamp: false as const };
}

// 呼び出し側
const paramText = e.parameter.text;
if (!paramText) return ContentService.createTextOutput('チェックしたいスレッドのURLを引数で渡してください。');

const { threadUrl, argStamp } = extractUrlAndStampFromParameter(paramText);
if (argStamp === false) {
  return ContentService.createTextOutput('第1引数にスレッドのurl、第2引数にスタンプを指定してください!スタンプは指定しなくても構いません。');
}
  • スラッシュコマンドの引数は doPost 関数の引数 e: GoogleAppsScript.Events.DoPost のプロパティ e.parameter.text から取得可能。
  • 引数に半角/全角スペースがあるかどうか(/\s+| +/.text())で第2引数の有無を確認。
  • 第2引数が引数が指定されている場合はそれが絵文字かどうかチェック(/^:(.+):$/.test())する。

2. リアクションをしたユーザーID一覧を取得する

search.messages メソッドからスレッドの channelIdts(タイムスタンプ)を取得し、conversations.replies メソッドからリアクションをしたメンバー一覧を取得する。

function getThreadData(threadUrl: string) {
  try {
    const messages = UrlFetchApp.fetch('https://slack.com/api/search.messages', {
      headers: { Authorization: `Bearer ${SLACK_API_TOKEN}` },
      payload: {
        query: threadUrl,
      },
    });

    const messagesBody = JSON.parse(messages.getContentText());
    const channelId = messagesBody.messages.matches[0].channel.id;
    const ts = messagesBody.messages.matches[0].ts;

    return typeof channelId === 'string' && typeof ts === 'string' ? { channelId, ts } : false;
  } catch (e) {
    return false;
  }
}
  • 「特定のスレッドにリアクションしたユーザー」を取得できるメソッド conversations.replies を実行するために、スレッドurlの channelIdts が必要なため、 search.messages メソッドからそれらを取得する。payloadquery にスレッドurlを指定することでスレッド情報を取得可能。
function getReactedUserIds({ channelId, ts }: { channelId: string; ts: string }, argStamp: string) {
  try {
    const replies = UrlFetchApp.fetch('https://slack.com/api/conversations.replies', {
      headers: { Authorization: `Bearer ${SLACK_API_TOKEN}` },
      payload: {
        channel: channelId,
        ts,
      },
    });

    const repliesBody = JSON.parse(replies.getContentText());
    const reactions: {
      name: string;
      users: string[];
    }[] = repliesBody.messages[0].reactions;
    if (typeof reactions === 'undefined') return []; // スタンプを押したユーザーがいない場合

    // 引数にスタンプを指定したかどうか
    if (argStamp) {
      const reactionUserIds = reactions.find((reaction) => reaction.name === argStamp.replaceAll(':', ''))?.users ?? [];
      return reactionUserIds;
    } else {
      const _reactionUserIds = reactions.flatMap((reaction) => reaction.users);
      const reactionUserIds = Array.from(new Set(_reactionUserIds)); // 重複削除(いずれか1つ以上のスタンプを押しているか)

      return reactionUserIds;
    }
  } catch (e) {
    return false;
  }
}
  • conversations.replies メソッドの返り値messages.reactionsから、リアクションの絵文字とユーザーのIDを取得。
  • スラッシュコマンドの第2引数に絵文字が指定されていた場合、messages.reactions.name から一致するリアクションを絞り込み、reactions.users からユーザーIDの一覧を取得。
  • スラッシュコマンドの第2引数に絵文字が指定されていない場合、なんらかのリアクションをつけたユーザーを一覧で取得。重複は削除する(Array.from(new Set(_reactionUserIds)))。

3. チャンネルのメンバー一覧を取得し、リアクションをした/していないメンバーを分類する

conversations.members メソッドからチャンネルのメンバー一覧を取得し、リアクションをした/していないメンバーを分類する

function getUserListOfReactedOrNot(channelId: string, reactedUserIds: string[]) {
  // - あらかじめ排除しておきたいユーザーのIDを配列に格納する。(Pollyやbotなど)
  const excludeIds = ['xxx'];

  try {
    const allChannelMembers = UrlFetchApp.fetch('https://slack.com/api/conversations.members', {
      headers: { Authorization: `Bearer ${SLACK_API_TOKEN}` },
      payload: {
        channel: channelId,
      },
    });

    const channelMemberIds = JSON.parse(allChannelMembers.getContentText()).members as string[];
    const reactedUsers: string[] = [];
    const nonReactedUsers: string[] = [];
    for (const id of channelMemberIds) {
      if (excludeIds.includes(id)) continue;
      reactedUserIds.includes(id) ? reactedUsers.push(`<@${id}>`) : nonReactedUsers.push(`<@${id}>`);
    }

    return { reactedUsers, nonReactedUsers };
  } catch (e) {
    return false;
  }
}
  • conversations.members メソッドからチャンネルのメンバー一覧を取得、リアクションした/していないユーザーそれぞれで配列に格納する。
  • ユーザーIDを <@userId> の形式の文字列で管理することで、Slackに送信した際にメンション扱いになり「ユーザーID → ユーザー名」の変換が自動で行われるようになる。前述の通りエフェメラルメッセージで送信されるため実際にメンバーにメンションが飛ぶことはない。

4. Slack にメンバー一覧を送信する

GAS で Slack にメンバー一覧を ContentService.createTextOutput メソッドで送信する。

  const { reactedUsers, nonReactedUsers } = usersList;
  return ContentService.createTextOutput(
    `【${argStamp || 'スタンプ'}つけた人】\n${returnLogText(reactedUsers)}\n\n【${argStamp || 'スタンプ'}つけてない人】\n${returnLogText(nonReactedUsers)}`
  );

所感

今回初めて GAS を書いたため、諸々ベストプラクティスではない実装となっているかもしれません。改善の余地ありです。

  • エラー時に Slack にテキストを送信するようにしているが、これがベストなのか?
  • 一度の処理で API と合計で3度もやりとりしているが、もっと通信を削減する方法はないか?
  • スラッシュコマンドの引数からスレッドurlと絵文字を切り離す処理を力技で行っているが、よりよい方法はあるのか?
  • ハンドリングできていないエラーパターンがあるか?

おわりに

リアクションを収集する必要があるスレッドで目視確認がつらいと感じている方はぜひ導入検討してみてください!
スラッシュコマンドはワークスペース内のメンバーなら誰でも使えるので、きっと誰かから感謝されるかもしれません。きっと。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1