作ったもの
Slack で「完了したらリアクションして」という連絡事項系のスレッドがあります。
現時点で誰がリアクションした/していないのかを確認したいという場合、以下の構文でスラッシュコマンドを実行します。コマンドは任意に設定可能です。
/checker {スレッドのurl}
チャンネル内のメンバーのうち、スレッドにリアクションした/していないメンバーの一覧を表示します。エフェメラルメッセージ で送信されるため、メンバーにメンションが飛ぶことはありません。
スラッシュコマンドの第2引数に絵文字を指定することで、その絵文字のリアクションをつけた/つけていないメンバーに絞り込むこともできます。
/checker {スレッドのurl} {:絵文字:}
実装内容
環境
スラッシュコマンドで Google Apps Script(以降 GAS)を呼び出して Slack API を実行しています。
GAS は clasp および TypeScript を使ってローカルでコーディングしています。
環境構築については以下の記事を参考にしてください。
ソースコード
コンパイル前の TypeScript で掲載します。
各ユーザーIDや Slack へ送信するメッセージは適宜変更してください。
ソースコード全文
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をコピーしておきます。
GAS 側でのやることは以上です。
Slack App でやること
1. App の作成
Your Apps ページの「Create New App」からアプリを新規作成します。
「From scratch」から任意のアプリ名とインストールしたいワークスペースを選択します。
2. パーミッションの設定
「Features > OAuth & Permissions」画面の「Scopes > User Token Scopes」からパーミッションのスコープを設定します。
必要なスコープは前述の通りです。
channels:history
channels:read
groups:history
groups:read
mpim:history
mpim:read
search:read
3. スラッシュコマンドの作成
「Features > Slash Commands」画面の「Create New Command」からスラッシュコマンドを作成します。
必要な情報を設定します。
- 「Command」に任意のコマンドを設定。ユニークであると望ましいです。
- 「Request URL」に GAS でデプロイしたURLを貼り付けます。
4. Slack のワークスペースにインストール
「Settings > Install App」画面の「Install to Workspace」からワークスペースにアプリをインストールします。
これで Slack 内でスラッシュコマンドを呼び出せるようになります。
コード解説
以下はコードの解説となります。ご興味のある方のみご覧ください!
0. APIトークンを GAS の環境変数に設定する
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
メソッドからスレッドの channelId
と ts
(タイムスタンプ)を取得し、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のchannelId
とts
が必要なため、search.messages
メソッドからそれらを取得する。payload
のquery
にスレッド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と絵文字を切り離す処理を力技で行っているが、よりよい方法はあるのか?
- ハンドリングできていないエラーパターンがあるか?
おわりに
リアクションを収集する必要があるスレッドで目視確認がつらいと感じている方はぜひ導入検討してみてください!
スラッシュコマンドはワークスペース内のメンバーなら誰でも使えるので、きっと誰かから感謝されるかもしれません。きっと。