この記事はユニファAdvent Calendar 2023の8日目の記事です。
こんにちは。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 functionality
のSlach Commands
を選択してCreate New Command
Command
に設定したいコマンドを入力- 今回は
/reactionscheck
としている
- 今回は
Request URL
に1で取得したウェブアプリのURLを入力してSave
3.slackのトークン取得
- sidemenuの
OAuth & Permissions
をクリックしScopes
のBot Token Scopes
のAdd an OAuth Scope
をクリックし、下記のOAuth Scopeを追加channels:read
commands
groups:read
im:read
mpim:read
reactions:read
usergroups:read
OAuth Tokens for Your Workspace
でInstall to Workspace
をクリック
4.GASのスクリプトプロパティの設定とコード修正
- プロジェクトの設定を開き、スクリプトプロパティに下記を設定
SLACK_BOT_TOKEN
:3で取得したBot User OAuth Token
SLACK_TOKEN
:2で作成したアプリのBasic Information
のApp 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
グループ名はグループメンションする際の「@」を除いたものの完全一致となります。 グループを複数指定することもできます。半角スペースを開けて続けて記載してください。
反省点とまとめ
このリアクションチェックの使い方資料を作成し、全社に展開してみたもののあまり利用されていませんでした。 おそらくスラッシュコマンドを利用することで、利用ハードルが上がってしまったのではないかと思っています。
プロダクト視点で考えると、スラッシュコマンドではなく例えば特定のスタンプを押すとこのコマンドが発火するというように、トリガーをもっと簡単なものにした方がユーザーフレンドリーだなと思います。
便利なものだとしても、利用ハードルが高いとなかなか利用は進まないですね。 反省点として今後のプロダクト開発に活かしていきたいと思います。
ユニファでは、一緒にはたらく仲間を募集しています!