🔔

【GAS × Slack】Slack スタンプでアンケートを取り、サクッと未対応者にリマインドを送れるアプリ

2023/02/13に公開

はじめに

「対象者に必ず確認してもらいたい内容」を Slack 上で発信する管理部門の方やマネージャー・プロジェクト推進者の方、飲み会幹事の方必見です!

こんな方におすすめ

  • 人事や経理関連の社員に必ず確認してほしい連絡を行う管理部門の方
  • チーム全体への発信が多いマネージャーの方
  • 出欠確認に苦労する飲み会幹事の方
  • プロジェクト推進者の方
  • 上記の方向けに業務効率化を図っている方

こんなことはありませんか?

  • 大人数に向けて対応依頼のメッセージを送った後、未対応者がいないか確認するために、対応完了スタンプを押していない人を目視で洗い出していませんか?
  • 大人数に向けて簡単なアンケートを取る際に、Google フォームを作成して、未対応者確認のために関数を組んでいませんか?

誰が未対応か、いちいちスタンプを確認したり関数を組んで判断するのは面倒くさいと思います。
また、未対応者を確認した後は、最終的に彼らに対してリマインドを行うのではないかと思います。

そこで、リマインド対象の投稿に対して Slack アプリを起動させることで、よしなにリマインドしてくれる、そんなアプリを作ってみました。

システムの流れ

まず、リマインドしたい投稿にスレッドを使って返信します。
※ その際、今回作成する Slack アプリへのメンションは必須です。

返信がなされると、Slack の API を通してアプリへリクエストが送られます。

アプリ側では、受け取った情報をもとに Slack へ投稿する処理(今回はGASでコードを書きます)を実行し、Slack の API を通して、実際に Slack 上でリマインドが行われる、という仕組みです。

仕様

今回は以下の仕様(かなりざっくりしてますが)でアプリを作成していきます。

  • アプリにメンションがされている投稿のみを検知
  • 対応/未対応を判定するスタンプを限定
  • リマインドの対象となる投稿の条件
    • 特定の文字列を含む(例 : リマインドして)
    • スレッドを使用している
    • 個人へのメンションがされている
      • @channel@here、ユーザーグループに対してメンションがされている場合はリマインドの対象外(今後改修予定)
  • 「メンションされた人」と「特定のスタンプを押した人」の差分を取得し、その差分に対してリマインドを行う
  • リマインドに失敗やリマインド対象外、リマインドの必要性がない場合は、その旨を投稿する
  • リマインド内容
    • メンション
    • 定型分(例 : 確認してください)
    • リマインド対象の投稿 URL

Slack アプリの作成

Slack アプリの作成方法については、色んな記事にまとまっているので、ぜひググってみてください!
こちらの記事などが参考になるかと思います。

アプリの設定

アプリの作成後は、諸々の設定を行います。
アプリの Slack 上での表示名やアイコン画像なども設定できますが、この記事では、アプリが使える Slack API メソッドの設定を取り扱います。

使用するメソッド

Bot Token Scopes

  • app_mentions:read
  • channels:history
  • chat:write
  • chat:write:customize
  • emoji:read
  • groups:history
  • reactions:read
  • usergroups:read

Subscribe to bot events

  • app_mention

※ scope の定義については、こちらに一覧としてまとまっています。

使用するAPI

コーディング

ここまで来たらいよいよコーディングです。
今回は、GAS でコードを書いていきます。

Slack の challenge 認証

まずは、以下の処理を書いてみます。

function doPost(e) {
  const postData = JSON.parse(e.postData.getDataAsString());

  // App側で指定したURLのverification(検証)
  // 飛んできたJSONのtypeがurl_verificationのときに、challengeに入っている文字列をそのまま送り返すとverificationが完了する仕組み
  try {
    if (postData.type == 'url_verification') {
      return ContentService.createTextOutput(postData.challenge);
    }
  }
  catch (ex) {
    Logger.log(ex);
  }
}

doPost()は、Web アプリに対して POST リクエストが送られたときに実行される関数です。
引数のeですが、こちらは「イベント情報」を表します。

イベント情報の中身について、具体的な説明はこちらに書いてあります。
参考 : https://developers.google.com/apps-script/guides/web?hl=ja#request_parameters

try-catch 文の概要については、コメントアウトにも記載していますが、GAS × Slack の連携をする上では必須な処理になります。
簡単に説明しますと、今回のように、Slack 上のイベントをトリガーにして何かしらの処理を実行したい場合、
リクエストを受ける処理( GAS )を事前に Slack 側に認証させてあげる必要があります。
その際、Slack 側には、challenge パラメータというものをレスポンスとして返す必要があるため、このような処理を記載しております。

ここまで完了したら、一旦コードを保存して Web アプリとしてデプロイしてみましょう(添付画像参照)。

デプロイが完了したら、WebアプリのURLをコピーして、「Event Subscriptions」の Request URL の欄に設定しましょう(添付画像参照)。

「Verified!」と表示されたら、無事認証が完了です。

リマインドの実行判定

ここからは、最初に決定した仕様に基づいてコードを書いていきます。

const postedText = postData.event.text; // 投稿内容を取得

// 投稿内容に指定文字列が入っていない場合
if (postedText && !postedText.includes('リマインド')) {
  text = 'リマインドに失敗しました。「リマインドして」と言ってみてください';
  chatPostMessage(postData, null, text, null, false);
  return;
}

// 投稿が返信でない場合
if (!postData.event.thread_ts) {
  text = 'リマインドに失敗しました(リマインド対象の投稿が存在しないため)';
  chatPostMessage(postData, null, text, null, false);
  return;
}

アプリ側の設定( Subscribe to bot events で指定した app_mention )にて、アプリへメンションされている投稿のみを検知できるようになりましたが、「特定の文字列を含む投稿のみを対象とする」という仕様にはまだ対応していません。
そのため、上記の処理が必要となります。

また、chatPostMessage()については、doPost()の外側で以下のように定義します。

function chatPostMessage(postData, slackID = [], text = null, link = null, isRemindMessage = true){
  const accessToken = PropertiesService.getScriptProperties().getProperty('SLACK_APP_TOKEN'); // スクリプトプロパティからSlackのAccess Tokenを取得
  const channelId = postData.event.channel; // 宛先のチャンネルIDを取得

  if (isRemindMessage) {
    const message_options = {
      'method' : 'post',
      'contentType' : 'application/x-www-form-urlencoded',
      'payload' : {
        'token' : accessToken,
        'channel' : channelId,
        'text' : slackID + '\nリマインドされています。以下のURLをご確認ください!\n' + link,
        'thread_ts' : postData.event.thread_ts
      }
    };
  } else {
    const messageOptions = {
      'method' : 'post',
      'contentType' : 'application/x-www-form-urlencoded',
      'payload' : {
        'token' : accessToken,
        'channel' : channelId,
        'text' : text,
        'thread_ts' : postData.event.thread_ts
      }
    };
  }

  UrlFetchApp.fetch('https://slack.com/api/chat.postMessage', messageOptions);
}

リマインド対象となる投稿情報の取得

コーディングはまだまだ続きます。
次は、リマインド対象となる投稿情報を取得していきます。
※ 親メッセージとは、以後、スレッドの最上部にあるメッセージ、すなわちリマインド対象の投稿を指します。

let response =  = conversationsReplies(postData);
const parentMessage = JSON.parse(response);
const parentMessageText = parentMessage.messages[0].text; // 親メッセージの文章を取得

conversationsReplies()というメソッドの定義がまだだったので、以下に記載します。

function conversationsReplies(postData) {
  const channelId = postData.event.channel; // 宛先のチャンネルIDを取得
  const threadTs = postData.event.thread_ts; // 親メッセージのIDを取得

  let messageOptions = {
    'method' : 'post',
    'contentType' : 'application/x-www-form-urlencoded',
    'payload' : {
      'token' : accessToken,
      'channel' : channelId,
      'ts' : threadTs
    }
  };

  return UrlFetchApp.fetch('https://slack.com/api/conversations.replies', messageOptions);
}

accessTokenについては、他のメソッドでも使用するかつ、定数なので、クラス配下で以下のように定義します。

const accessToken = PropertiesService.getScriptProperties().getProperty('SLACK_APP_TOKEN');

それでは、親メッセージ情報が取得できたので、リマインド対象となるかどうか、判定していきます。

// 親メッセージ内で誰にもメンションされていない場合
if (!parentMessageText.includes('<@') && !parentMessageText.includes('<!subteam') && !parentMessageText.includes('<!channel>') && !parentMessageText.includes('<!here>')) {
  text = 'リマインドに失敗しました(誰にもメンションがされていないため)';
  chatPostMessage(postData, null, text, null, false);
  return;
}

// 親メッセージのメンションタイプが@channelか@hereの場合
if (parentMessageText.includes('<!channel>') || parentMessageText.includes('<!here>')) {
  text = '現状、@channelと@hereに対してはリマインドができません';
  chatPostMessage(postData, null, text, null, false);
  return;
}

// 親メッセージのメンション対象がユーザーグループのとき
if (parentMessageText.includes('<!subteam')) {
  text = '現状、ユーザーグループに対してはリマインドができません';
  chatPostMessage(postData, null, text, null, false);
  return;
}

リマインド処理

いよいよここからは、未対応メンバーへのリマインド処理に焦点を当てます。
親メッセージ内で「誰にメンションされているのか」を認識したいので、メッセージ内容の中から"<@"が含まれる文字列を抽出します。

const formattedParentMessageText = parentMessageText.split(/\r\n|\n|\s/); // 親メッセージを整形
const mentionedUserSlackID = []; // 親メッセージ内でメンションされたメンバーのSlackID

for (let item of formattedParentMessageText) {
  if (item.match('<@')) {
    mentionedUserSlackID.push(item);
  }
}

次に、指定の絵文字を送ったメンバー情報を抽出します。

response = reactionsGet(postData);
const json = JSON.parse(response);
const reactions = json.message.reactions; // スタンプを押した人などのリアクション情報
const link = json.message.permalink; // 親メッセージのURL

let sendEmojiUserSlackId = []; // 絵文字を送ったメンバーのSlackID
if (reactions) {
  for (let reaction of reactions) {
    let reactionName = reaction.name;
    if (reactionName == 'sumi' || reactionName == 'zumi' || reactionName == 'done' || reactionName == 'done2') {
      let reactionUsers = reaction.users;
      for (let user of reactionUsers) {
        let userSlackId = '<@' + user + '>';
        sendEmojiUserSlackId.push(userSlackId);
      }
    }
  }
}

私が知る限り、弊社では、対応完了を表す絵文字が4種類存在するので、それら全ての絵文字名を条件に持たせます。
ちなみに、4種類の絵文字は以下の通りです(左から、:sumi:, :zumi:, :done:, :done2:)。
個人的には :done2: が一番好きです笑

reactionsGet()というメソッドの定義がまだだったので、以下に記載します。

function reactionsGet(postData) {
  const channelId = postData.event.channel; // 宛先のチャンネルIDを取得
  const threadTs = postData.event.thread_ts; // 親メッセージのIDを取得

  let = messageOptions = {
    'method' : 'post',
    'contentType' : 'application/x-www-form-urlencoded',
    'payload' : {
      'token' : accessToken,
      'channel' : channelId,
      'timestamp' : threadTs
    }
  };

  return UrlFetchApp.fetch('https://slack.com/api/reactions.get', messageOptions);
}

さて、絵文字を送ったメンバーの抽出ができたので、親メッセージ内でメンションされたメンバーとの差分を出していきたい、
ところなのですが、sendEmojiUserSlackIdという配列の中には、重複する SlackID が存在する可能性があります。
なぜなら1人の人が複数の対応完了スタンプを押すことが考えられるからです。

したがって、配列の中から重複する値を削除するために、以下の処理を加えます。

sendEmojiUserSlackId = sendEmojiUserSlackId.filter((value, index, self) => self.indexOf(value) === index); // 重複データを削除

いよいよ、終盤です。
今度こそ、親メッセージ内でメンションされたものの、まだ対応完了スタンプを押していないメンバーを抽出します。

// スタンプを押したメンバーがいる場合、差分を抽出する
if (sendEmojiUserSlackId) {
  mentionTargetUserSlackId = mentionedUserSlackID.filter(index => sendEmojiUserSlackId.indexOf(index) === -1);
}

// リマインドを送るべきメンバーがいる場合
if (Object.keys(mentionTargetUserSlackId).length !== 0) {
  chatPostMessage(postData, mentionTargetUserSlackId, null, link, true);
} else {
  text = '全員、対応完了です';
  chatPostMessage(postData, null, text, link, false);
}

これにて、コーディングは終了です。
最新のコードをデプロイして、Web アプリの URL を「Event Subscriptions」の Request URL の欄に入力しましょう。
あとは、作成したリマインドアプリをチャンネルに招待して、ぜひ使ってみてみてください!

最後に

今回は、GAS で Slack アプリを作成してみました。
GAS を使うことでサーバーレスかつ無料でアプリが作成できますし、プログラミング初心者の方でも積極的にチャレンジしてみてください!

最後まで読んでいただきありがとうございました。

Hajimari Tech Media

Discussion