Slackスレッド内の質問を収集するbotを作ってみた
🤖

Slackスレッド内の質問を収集するbotを作ってみた

こんにちは、エンペイのエンジニアの小口です。

この記事は エンペイ Advent Calendar 2023 の13日目の投稿です 🎄

昨日は中澤さんの タイムライン振り返りをリモートで実施しました でした!

エンペイでは社内DX化が推進されており、エンジニアが便利ツールを作る機会もあります。

2023年にもいくつか作られたものはありましたが、その中でも作成にあまり時間がかからなかったのに社内にかなり浸透したSlack botがあったので紹介します。

botを導入した背景

盛り上がるミーティング実況チャンネル

エンペイには #all-mtg-live というミーティングの実況チャンネルがあります(森塚さんの記事でも紹介されていましたね)。

エンペイはフルリモートワークを実施しており、ミーティングはGatherやGoogle Meetで開催しています。社員も増えてきて全員がマイクを通して発言するのは難しく、代わりにSlackのスレッドに投稿して盛り上がっています。

特に参加人数の多い打ち合わせはスレッド内に300~400くらいの投稿が発生します。

image

司会者はこのスレッドを見ながら進行し、質問があれば拾うという運用が行われています。

拾われない質問が発生

しかし、盛り上がっているがゆえに見落とされてしまう質問が出てきました。

司会者:スレッドの海の中に漂う質問を探すのが大変🥺

質問者:見落とされたときはもう一度同じ質問を投稿する🥺

といったつらさがあり、こんな要望も上がっていました

image

それ、技術で解決できますね!

ということで質問を収集してくれるSlack botを作ることにしました。

botの仕様

とにかく早くこの課題を解決したかったので、あまり作りこまずシンプルな仕様にしました。

  • スレッドの中で拾ってほしい質問には特定のリアクションをつける
  • botにメンションしたら、このリアクションがついた投稿内容と投稿者が一覧で返される
  • 未回答・回答済みの質問が把握できる
  • 質問と回答を紐付けて一覧にするのは仕様が複雑になりそうだったので対応しない

実際どんなかんじで使われているの?

質問を収集している様子

こちらはエンペイに新しい社員が入ったときに開催される歓迎会でのやりとりです。

拾ってほしい質問に対し、 質問 というリアクションをつけます。

image

ある程度質問が投稿集まったら question_collector という名前の質問収集botにメンションします。すると、 質問 のリアクションがついた投稿を一覧で返してくれます。

ちなみにエンペイには野球好きな人とサウナ好きな人が一定数います

image

xxxさんの部分はSlackのユーザー名が表示されます。

回答済みの質問は先頭に 回答済み と表示され、未回答の場合は ? が表示されるようにしました。これでパッと回答・未回答の区別がつきます。

司会者はこの一覧を見て質問に回答するので漏れなく回答できます。

botでは質問と回答を紐付けできていませんが、元の投稿に回答内容を後から追加すれば質問と回答をセットで見ることも可能です。

社内の喜びの声(読者の方にはあまりメリットないかもですが、作者が後から見てニヤニヤできるので残しておきます)

image
image
image

タスクの管理にも使えた

これは想定していなかったのですがタスク管理にも使われています。

image

例えば、エンペイでは新しい機能をリリースする前に開発者やPM、QAエンジニアで集まって新機能を触りバグ出ししているのですが、

  1. バグかもと思った内容をスレッドに投稿し 質問のリアクションをつける
  2. バグではなかった場合は 回答済み のリアクションをつける
  3. バグ出しが終わったら 質問収集botにメンションし、出てきたバグを一覧にしてもらう

といった使われ方もしています。

image

機能のシンプルさゆえに、こういった使われ方にも対応できたのかなと思っています。

簡単にコードの紹介

質問収集botはGAS+Slackアプリでできています。

コードの全体像はこちらです。

const BOT_USER_ID = "BOT_USER_IDの値";
const SLACK_ACCESS_TOKEN = "SLACK_ACCESS_TOKENの値";
const REACTED_EMOJI = "質問のリアクションの名前"
const ANSWERED_EMOJI = "回答済みのリアクションの名前"

const STATUS_QUESTION_EMOJI = "botの投稿に付ける質問リアクションの名前"
const STATUS_ANSWERED_EMOJI = "botの投稿に付ける回答済みのリアクションの名前"

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

  if (params.challenge) {
    return ContentService.createTextOutput(params.challenge);
  }

  // Botがメンションされた場合のみ処理を実行
  if (isBotMentioned(params.event.text, BOT_USER_ID)) {
    handleMention(SLACK_ACCESS_TOKEN, params);
  }
}

// Botがメンションされたかをチェックする
function isBotMentioned(text, botUserId) {
  return text.includes(`<@${botUserId}>`);
}

// Botにメンションされたら質問の一覧を返す
function handleMention(token, params) {
  const messages = getMessagesWithReaction(token, params.event.channel, params.event.thread_ts, REACTED_EMOJI);

	postMessage(
		token,
	  params.event.channel, 
		params.event.thread_ts, 
		messages.length === 0 
			? "質問はありません"
			: createSummary(token, messages, ANSWERED_EMOJI)
	);
}

// メッセージを作成する
function createSummary(token, messages) {
  return messages.map(message => {
    const hasDone = message.reactions.some(r => r.name === ANSWERED_EMOJI)
		const status = hasDone 
			? STATUS_ANSWERED_EMOJI
		  : STATUS_QUESTION_EMOJI
    const userName = getUserInfo(token, message.user).user.name
    return `${status}${userName}さんからの質問\n${message.text}\n\n`
  }).join("");
}

function createParam(token, payload) {
	return {
    "method": "get",
    payload,
    "headers": {
      "Authorization": `Bearer ${token}`,
    },
    "muteHttpExceptions": true,
  };
}

// 投稿したユーザーの情報を取得する
function getUserInfo(token, user) {
  const url = "https://slack.com/api/users.info";

  const response = UrlFetchApp.fetch(url, createParam(token, {
    token,
    user,
  }));
  const userInfo = JSON.parse(response.getContentText());

  return userInfo;
}

// 指定したリアクションがついたメッセージを取得する
function getMessagesWithReaction(token, channel, thread_ts, reaction) {
  const url = "https://slack.com/api/conversations.replies";
  
  const response = UrlFetchApp.fetch(url, createParam(token, {
    channel,
    "ts": thread_ts,
    "limit": 1000,
    "inclusive": true
  }));

  const messages = JSON.parse(response.getContentText()).messages;

  return messages.filter((message) => 
		message.reactions?.some((r) => r.name === reaction)
  );
}

// スレッド内にメッセージを投稿する
function postMessage(token, channel, thread_ts, message) {
  const url = "https://slack.com/api/chat.postMessage";

  const payload = {
    "token" : token,
    "channel" : channel,
    "thread_ts": thread_ts,
    "text" : message
  };
  
  const params = {
    "method" : "post",
    "payload" : payload
  };
  
  UrlFetchApp.fetch(url, params);
}

botにメンションが飛ぶと doPost が実行され、botにメンションされたときだけhandleMention を実行します。

handleMentionでやっていること
使っているSlack API
スレッド内に投稿されたメッセージの取得(Slack API)+ 取得したメッセージの中から 質問 のリアクションがついているメッセージをフィルタリング
フィルタリングしたメッセージを元にスレッドに質問一覧を投稿する

Slackアプリ側はEvent SubsriptionsをOnにして、Request URLにGASのURLを設定しています。

おわりに

社内のちょっとしたモヤモヤを技術で解決することは楽しいですね🙌

今後もこういった活動に継続して取り組んでいきたいです。

明日は竹村さんが書きます!楽しみにしています😄