ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Slackの投稿にリアクションした人・してない人の一覧を取得するものをGASで作ってみた

この記事はユニファAdvent Calendar 2023の8日目の記事です。

adventar.org

こんにちは。PdMのきそです。

みなさんはslackを使ってますか?

Slackを利用している中で例えばチームのメンバーに 『xxの資料をmm/ddまでに更新してください! 終わった人は「済」スタンプつけてください!』 といった依頼をすることってありますよね??

そして締め切りが近づいた時に、まだ「済」スタンプがついていない人=資料を更新していない人に対してリマインドを送りたい!そんなことってありますよね??

でも「済」スタンプにマウスオーバーして、誰が作業が終わっているかを確認し、それを元にまだ作業が終わっていない人を洗い出すなんてことは少々面倒ですし、できればやりたくありません。

ということで、今回はプロダクトマネジメントとは一切関係なく、slackの投稿にリアクションした人・してない人の一覧を取得するものをGASで作ったのでそれについて備忘も兼ねて書いてみようと思います。

このコードでできること・できないこと

できること

  • slackの投稿に対して、スタンプを押した人が誰か・スタンプを押していない人が誰かの一覧を返す。
    • 実行結果は実行者にしか表示されません。

できないこと

  • fileのみ(テキストが一切含まれていない)の投稿に対して、スタンプを押した人が誰か・スタンプを押していない人が誰かの一覧を返す。
  • チャネルのメンバーからアプリ名を除外した一覧を返す

実装

1.GASの新規プロジェクトを作成し、webアプリケーションを公開

コードは後で書き換えるので、最初はデフォルトのままでも大丈夫です。

  • 右上のデプロイボタンから新しいデプロイを選択
  • 表示されたモーダルの種類の選択ウェブアプリを選択
  • アクセスできるユーザー全員を選択してデプロイ
    • 2で利用するウェブアプリのURLが発行されます
    • このウェブアプリのURLはデプロイの度に新しいものが発行されるので、コード編集した場合は、slack APIのRequest URLも変更が必要になります

2.slack APIでアプリとコマンドを作成

  • 利用したいワークスペースにサインインした状態でSlack APIにアクセス
  • Create New AppをクリックしFrom scratchを選択
  • App Nameを入力し、ワークスペースを選択してCreate App
  • Add features and functionalitySlach Commandsを選択してCreate New Command
  • Commandに設定したいコマンドを入力
    • 今回は /reactionscheckとしている
  • Request URLに1で取得したウェブアプリのURLを入力してSave

3.slackのトークン取得

  • sidemenuのOAuth & PermissionsをクリックしScopesBot Token ScopesAdd an OAuth Scopeをクリックし、下記のOAuth Scopeを追加
    • channels:read
    • commands
    • groups:read
    • im:read
    • mpim:read
    • reactions:read
    • usergroups:read
  • OAuth Tokens for Your WorkspaceInstall to Workspaceをクリック

4.GASのスクリプトプロパティの設定とコード修正

  • プロジェクトの設定を開き、スクリプトプロパティに下記を設定
    • SLACK_BOT_TOKEN:3で取得したBot User OAuth Token
    • SLACK_TOKEN:2で作成したアプリのBasic InformationApp Credentialsに表示されているVerification Token
  • エディタを開き下記コードを貼り付け
function doPost(e) {
  const slack_token = PropertiesService.getScriptProperties().getProperty("SLACK_TOKEN");
  if (e.postData.type === "application/x-www-form-urlencoded") {
    if (e.parameter.token !== slack_token ) {
      return ContentService.createTextOutput("Invalid token");
    }
    if (e.parameter.command === "/reactionscheck") {
      const bot_token = PropertiesService.getScriptProperties().getProperty("SLACK_BOT_TOKEN");
      const postUrl = e.parameter.text;
      // テキストが空の時
      if (postUrl == '') {
        return ContentService.createTextOutput('slackのURLを指定してください');
      }
      const team_id = e.parameter.team_id;
      const postUrlArray = postUrl.split(' ');
      const targetUrl = postUrlArray[0];
      // slack以外のURLが入っている時・fileのみの時
      if (!targetUrl.match(/https:\/\/.+\.slack\.com\/archives\/.+\/.+/)) {
        return ContentService.createTextOutput('slackのURLを指定してください');
      }
      const targetUrlArray = targetUrl.split('/');
      // URLが不完全な時
      if (targetUrlArray < 5) {
        return ContentService.createTextOutput('正しいslackのURLを指定してください');
      }
      const channel = targetUrlArray[4];
      // 実行したチャネルとslackのURLのチャネルidが異なる時
      if (e.parameter.channel_id != channel) {
        return ContentService.createTextOutput('チャネル内のslackのURLを指定してください');
      }
      const ts = targetUrlArray[5].substring(1,11) + '.' + targetUrlArray[5].substring(11,17);
      let baseMembers = [];

      if (postUrlArray.length == 1) {
        // チャネルのユーザーID取得
        baseMembers = getChannelUsersList(bot_token,channel);
      } else {
        // グループ一覧・ユーザーID取得
        baseMembers = getGroupList(bot_token,postUrlArray,team_id);
      }

      // リアクションしたユーザーID取得
      let reactionMembers = getReactionUsersList(bot_token,channel,ts);

      // リアクションした人・してない人の表示作成
      if (baseMembers == 'error' || reactionMembers == 'error') {
        return ContentService.createTextOutput('正しいslackのURLを指定してください');
      } else if (reactionMembers == 'not_in_channel') {
        return ContentService.createTextOutput('チャネルにアプリを追加してください');
      } else if (baseMembers.length == 0) {
        return ContentService.createTextOutput('正しいグループ名を指定してください');
      } else {
        let result = createResult(reactionMembers,baseMembers);
        return ContentService.createTextOutput(result);
      }
    }
  }  
}

// チャネルのユーザーID取得
function getChannelUsersList(bot_token,channel_id) {
  const channelUsersGetUrl = 'https://slack.com/api/conversations.members';
  const channelUsersPayload = {'token':bot_token, 'channel': channel_id, 'pretty': '1'};
  let channelUsersGetParams = {'method': 'get', 'payload': channelUsersPayload};
  let channelUsersList = UrlFetchApp.fetch(channelUsersGetUrl, channelUsersGetParams);
  channelUsersList = channelUsersList.getContentText();
  channelUsersList = JSON.parse(channelUsersList);
  if (channelUsersList.error != undefined) {
    return 'error';
  }
  return channelUsersList.members;
}

// リアクションしたユーザーID取得
function getReactionUsersList(bot_token,channel,ts) {
  const reactionGetUrl = 'https://slack.com/api/reactions.get';
  const getpayload = {'token':bot_token, 'channel': channel, 'timestamp': ts, 'pretty': '1'};
  let getParams = {'method': 'get', 'payload': getpayload};
  let reactionsUserId = UrlFetchApp.fetch(reactionGetUrl, getParams);
  reactionsUserId = reactionsUserId.getContentText();
  reactionsUserId = JSON.parse(reactionsUserId);
  if (reactionsUserId.error != undefined) {
    if (reactionsUserId.error == 'not_in_channel') {
      return 'not_in_channel';
    } else {
      return 'error';
    }
  }
  if (reactionsUserId.message.reactions == undefined) {
    return 0;
  }
  return reactionsUserId.message.reactions;
}
// リアクションしていないユーザーID取得
function getNoReactionUsersList(reactionMembersUnique,baseMembers) {
  const noReactionMembers = baseMembers.reduce((res, userid) => {
    if (!reactionMembersUnique.includes(userid)) {
      res.push(userid);
    }
    return res;
  }, []);
  return noReactionMembers;
}

// グループ一覧・ユーザーID取得
function getGroupList(bot_token,postUrlArray,team_id) {
  postUrlArray.shift();
  const groupGetUrl = 'https://slack.com/api/usergroups.list';
  const groupGetpayload = {'token':bot_token, 'team_id': team_id, 'include_users': 'true', 'pretty': '1'};
  let groupGetParams = {'method': 'get', 'payload': groupGetpayload};
  let groupList = UrlFetchApp.fetch(groupGetUrl, groupGetParams);
  groupList = groupList.getContentText();
  groupList = JSON.parse(groupList);
  if (groupList.error != undefined) {
    return 'error';
  }
  groupList = groupList.usergroups;
  let groupMembers = groupList.reduce((res, groupInfo) => {
    if (postUrlArray.includes(groupInfo.handle)) {
      res.push(groupInfo.users);
    }
    return res;
  }, []);
  groupMembers = groupMembers.flat();
  groupMembers = [...new Set(groupMembers)];
  return groupMembers;
}

// リアクションした人・してない人の表示作成
function createResult(reactionMembers,baseMembers) {
  let returnText = '';
  let reactionMembersUnique =[];
  // baseMembersのうち、リアクションした人のリスト作成
  if (reactionMembers != 0) {
    let reactionDisplayMembers = reactionMembers.map(function(reactionInfo) {
    let userids = reactionInfo.users.reduce((res,userid) => {
      if (baseMembers.includes(userid)) {
        res.push(userid);
      }
      return res;
    },[]);
    return {
      'name' : reactionInfo.name,
      'users': userids,
      'count': userids.length
      }
    });
    // リアクションした人の表示作成
    for (var i=0; i<reactionDisplayMembers.length; i++) {
      if (reactionDisplayMembers[i].count != 0) {
        returnText += ':' + reactionDisplayMembers[i].name + ':(' + reactionDisplayMembers[i].count + '人)\n';
        for (var j=0; j<reactionDisplayMembers[i].users.length; j++) {
          returnText += '<@' + reactionDisplayMembers[i].users[j] + '> ';
          reactionMembersUnique.push(reactionDisplayMembers[i].users[j]);
        }
        returnText += '\n';
      }
    }
    reactionMembersUnique = [...new Set(reactionMembersUnique)];
  }

  // リアクションしていないユーザーID取得
  let noReactionMembersUnique = getNoReactionUsersList(reactionMembersUnique,baseMembers);
  // リアクションしてない人の表示作成
  returnText += 'リアクションなし(' + noReactionMembersUnique.length + '人)\n';
  for (var k=0; k<noReactionMembersUnique.length; k++) {
    returnText += '<@' + noReactionMembersUnique[k] + '> ';
  }
  return returnText;
}
  • 1の時と同様に、再度デプロイ

5.slack APIのSlash CommandsのRequest URL書き換え

  • sidemenuのSlash Commandsをクリックし、編集ボタン(鉛筆マーク)で編集画面を開く
  • Request URLに4でコード修正した後、デプロイして発行されたウェブアプリのURLを入力してSave

6.slackでこのコマンドを利用したいチャネルにアプリを追加

  • このコマンドを利用したいチャネルを開き、チャネル名をクリック
  • 表示されたモーダルのインテグレーションタブをクリックし、アプリを追加するボタンをクリック
  • 2で作成したアプリを選択し、追加

使い方と実行結果サンプル

  • チャネルのメンバーのリアクションを取得する時

    • /reactionscheck [slackURL]
    • 例)/reactionscheck https://xxx.slack.com/archives/XXXXXXXXX/p123456789012345
  • 特定のグループのメンバーのリアクションを取得する時

    • /reactionscheck [slackURL] [グループ名]
    • 例)/reactionscheck https://xxx.slack.com/archives/XXXXXXXXX/p123456789012345 team_pdm

グループ名はグループメンションする際の「@」を除いたものの完全一致となります。 グループを複数指定することもできます。半角スペースを開けて続けて記載してください。

実行結果サンプル

反省点とまとめ

このリアクションチェックの使い方資料を作成し、全社に展開してみたもののあまり利用されていませんでした。 おそらくスラッシュコマンドを利用することで、利用ハードルが上がってしまったのではないかと思っています。

プロダクト視点で考えると、スラッシュコマンドではなく例えば特定のスタンプを押すとこのコマンドが発火するというように、トリガーをもっと簡単なものにした方がユーザーフレンドリーだなと思います。

便利なものだとしても、利用ハードルが高いとなかなか利用は進まないですね。 反省点として今後のプロダクト開発に活かしていきたいと思います。

ユニファでは、一緒にはたらく仲間を募集しています!

unifa-e.com