SlackのユーザーグループメンションをアシストするアプリをCraft Functionsで作ってみた

はじめに

こんにちは、株式会社RightTouchで5/1からCustomer Engineer (CE) をすることになりました、川下です!

執筆時点では入社1ヶ月目ですが、本記事に記載のSlackアプリの開発自体は入社1週間目のタイミングで行うという、スピード重視の生き方をしています。

今回はKARTEの機能の一つであるCraft Functions(FaaS)を使ってSlackのユーザーグループをアシストする簡単なSlackアプリを作ったので、そのコード(JavaScript)と背景を記事にしてみました。

コード自体は比較的単純なため、判断などのWhy部分を少し厚めに書いていきます。

【この記事はこんな方におすすめです!】

  • Slackアプリ * FaaS(Craft Functions)でなにか作ってみたい方
  • RightTouchという会社の雰囲気を知りたい方

本来は当社やCEという職種についての説明もしたい所ですが、長くなってしまうためその役割は以下リンクに任せたいと思います。
https://www.wantedly.com/companies/righttouch/post_articles/874138

大まかに、「CEとはプロダクトとクライアントを繋ぐためになんでもやる職種」で「RightTouchは入社数日のメンバーにも自由にチャレンジさせてくれる職場」だと捉えていただければ大丈夫です!

導入が長くなってきましたが、着手した背景について少しだけ触れたいと思います。

当社では「承認より謝罪より事後報告」という、やるべきと思ったらまずは動いてみるという文化(マインド)があります。

入社直後は時間の余裕もあり社内の動きもよく見えるため、手が空いている内に少しでも人の負担を減らすためにできることをしようと思い、この文化に倣い着手しました。

実際、開発にあたって承認プロセスのようなものはなく、業務改善のためにできることは(現実的な範囲なら)何でもやってOKという寛容さと挑戦できる環境があるということを実感できました。

また、今回はKARTEの機能の一つであるCraft Functionsを利用していますが、FaaSであれば基本的に何でも問題ありません。

KARTE側の縛りにより、今回ライブラリは@slack/web-api を利用していますが、Lambdaなどを使うのであれば公式推奨の@slack/bolt をお薦めします。

最後に免罪符として、筆者はコーディング経験がまだ浅いため、コードの綺麗さについてはご容赦ください・・・!

長くなりましたが、それでは本編スタートです!!!

何を・どう作ったのか(What・How)

まずは動くものを見ていただくのが一番理解が早いと思いますので、Whatから記載をしていきます。

動作の概要

再度になりますが、今回作ったのは「Slackのユーザーグループから漏れてしまうゲストを救済するためのSlackアプリ」になります。

このアプリはグループ会社であるプレイドで既に作成されていたのですが、当社では使えなかったため今回自社専用のものを作ろうということで開発に当たりました。

そもそもユーザーグループとはなんぞや、の詳細な機能の説明は本家(ユーザーグループを作成する )に委ねさせていただきます。

大まかにいうと、Slackには「ユーザーグループ」という機能があり、1つのリンクでまとめて複数人にメンションが可能です。但し、例えばインターン生などのゲストはユーザーグループ機能を利用できず、メンションされないしメンションすることもできないので、これを補助してあげるSlackアプリを作ったという経緯です。

ゲストからみたグループメンションの景色は以下のようになります。疎外感を感じてしまいますね。

slack-app-01.png

デモ

実際の動作のキャプチャは以下です。

slack-app-02.png

Botを含めた誰かが、対象のグループメンションをつけるとそれをフックにゲストにメンションを自動で飛ばしてくれるという動きになっています。

また逆も可能で、例えばゲストが「@dev_all」と入力すると代わりにグループメンションを飛ばすこともできます。

slack-app-03.png

これらの2つの動きを簡単なシーケンス図で書いてみると、このような動きになります。

slack-app-04.png

後ろ側の動きはどちらのケースでも同様で、FaaS側でリクエストを受けた際の処理で動作に分岐をかけています。

そのためユーザーは基本的に何も考えず、いつも通りにメンションするだけでメンションのアシストが走るようになります。

但し、ユーザーグループとゲストの紐付けはGoogleSpreadsheet上で管理しているため、ゲストの出入りがあった場合はそこを更新する必要がある点だけは注意が必要です。

実際のコードと設定

ではいよいよお待ちかね、FaaS(Craft Functions)上にアップしているコードと設定の詳細を説明していきます。

FaaS側のコード

少々長くなりますが、以下をそのままCraft Functionsのエディタに貼っています。

import { google } from "googleapis";
import { WebClient } from "@slack/web-api";

const COLUMN_OF_MENTION_NAMES = 0;
const COLUMN_OF_MENTION_IDS = 1;
const COLUMN_OF_USER_IDS = 2;

// slack web api Initialize
const slack = new WebClient("<% SLACK_BOT_USER_OAUTH_TOKEN %>");

/**
 * slackから送られてくるevent.blocksから、任意のtypeのelements(text or usergroup)を抽出する関数
 * @param { { type: string, elements: { type: string, elements: { type: string, text?: string, usergroup_id?: string }[] }[] }[] } blocks - メンションを含むメッセージ本文のリッチテキストブロック
 * @param { "text" | "usergroup" } type - 抽出したいtypeの文字列
 * @returns {string[]} - 指定されたtypeのelementsの値の配列
 */
const _getTargetElement = (blocks, type) => {
  const targetKey = type === "text" ? "text" : "usergroup_id";

  // event.textではゲストの発言かメンバーの発言か区別できないケースがあるため、リッチテキストブロックからtext element or group mention elementのみを取得し文字列に変換
  const richTextBlocks = blocks.filter((item) => item.type === "rich_text");
  
  const richTextSections = richTextBlocks
    .flatMap((richTextBlock) => {
      if (Object.hasOwn(richTextBlock, "elements")) {
        return richTextBlock.elements;
      }
      return null;
    })
    .filter((el) => el !== null)
    .filter((el) => el.type === "rich_text_section");

  const results = richTextSections
    .flatMap((richTextSection) => {
      if (Object.hasOwn(richTextSection, "elements")) {
        return richTextSection.elements;
      }
      return null;
    })
    .filter((el) => el !== null)
    .filter((el) => el.type === type)
    .map((el) => el[targetKey]);

  return results;
};

/**
 * グループメンションID(ex. SXXXXX)を抽出する関数
 * @param { { type: string, elements: { type: string, elements: { type: string, text?: string, usergroup_id?: string }[] }[] }[] } blocks - メッセージのリッチテキストを含むオブジェクト
 * @returns {string[]} - グループメンションのIDの配列
 */
const _getGroupMentionIds = (blocks) => {
  if (!blocks.length) return [];

  return _getTargetElement(blocks, "usergroup");
};

/**
 * グループメンションラベル(ex. @dev_support)を抽出する関数
 * @param { { type: string, elements: { type: string, elements: { type: string, text?: string, usergroup_id?: string }[] }[] }[] } blocks - メッセージのリッチテキストを含むオブジェクト
 * @returns {string[]} - グループメンションのラベルの配列
 */
const _getGroupMentionNames = (blocks) => {
  if (!blocks.length) return [];

  // event.textではゲストの発言かメンバーの発言か区別できないケースがあるため、リッチテキストブロックからtext elementのみを取得し文字列に変換
  let targetText = _getTargetElement(blocks, "text").join(" ");
  if (!targetText) return [];

  // 正規表現パターン
  const regexPattern = /(?<!<)\B@[0-9a-z_]\w*(?=[\s\n>]?)/g;
  // 全てのマッチを取得し、配列に格納
  return [...targetText.matchAll(regexPattern)].map((item) => item[0]);
};

/**
 * スプレッドシートからメンションすべきユーザーのID一覧を取得しメンションに変換するための関数
 * @param {{client_email: string, private_key: string}} serviceAccount - googleapisを使うための認証情報
 * @param {{log: (any) => {}}} logger - ログを出すための関数
 * @param {{get: (any) => any}} secret - シークレット変数を取得するためのCraft独自の関数
 * @returns {string[] | null} - メンションすべきユーザーのIDs(本当にstring[]かは要確認)
 */
const _getMasterData = async (logger, secret) => {
  try {
    // 認証用データの取得。secretはCraft Functions特有の環境変数取得用関数
    const secrets = await secret.get({ keys: ["<% SECRET_KEY_OF_GCP_SA %>"] });
    const googleServiceAccount = secrets["<% SECRET_KEY_OF_GCP_SA %>"];
    const serviceAccount = JSON.parse(googleServiceAccount);

    // google apiの初期化
    const jwtClient = new google.auth.JWT(
      serviceAccount.client_email,
      null,
      serviceAccount.private_key,
      ["https://www.googleapis.com/auth/spreadsheets"]
    );
    await jwtClient.authorize();
    const sheets = google.sheets({ version: "v4", auth: jwtClient });
    const sheetName = "<% SHEET_NAME %>";

    // 対応表を取得・返却
    const master = await sheets.spreadsheets.values.get({
      spreadsheetId: "<% SPREADSHEET_ID_OF_MASTER_DATA %>",
      range: `${sheetName}!A:C`,
    });
    return master.data.values || null;
  } catch (e) {
    logger.log(e);
    throw e;
  }
};

/**
 * getMasterDataで取得したグループとユーザーIDのマッピングからユーザーメンションに変換するための関数
 * @param {string | number[][]} masterData - スプレッドシートから取得したgroup mention IDと追加メンション対象ユーザーIDの配列
 * @param {string[]} groupMentionIds - チャット内で指定されたgroup mention IDの配列
 * @returns {string} - メンションすべきユーザーのIDsを文字列に変換したもの
 */
const _getUserMentionText = (masterData, groupMentionIds) => {
  const targetRows = masterData.filter((row) =>
    groupMentionIds.includes(row[COLUMN_OF_MENTION_IDS])
  );
  if (!targetRows.length) return "";

  const mentionNames = targetRows
    .map((row) => row[COLUMN_OF_MENTION_NAMES])
    .join(" ");

  const userIdsArray = targetRows
    .map((row) => row[COLUMN_OF_USER_IDS])
    .filter((userId) => userId !== undefined && userId !== null);
  if (!userIdsArray.length) return "";

  // 1列にカンマ区切りで複数のユーザーIDが入ることがあるため、一度すべてまとめてからsplit
  const userIds = userIdsArray
    .join(",")
    .trim()
    .split(",")
    .reduce((uniques, userId) => {
        return uniques.includes(`<@${userId}>`) ? uniques : [...uniques, `<@${userId}>`];
    }, [])
    .join(" ");

  return mentionNames + "\n" + userIds;
};

/**
 * getMasterDataで取得したグループとメンション名のマッピングからグループメンションに変換するための関数
 * @param {string | number[][]} masterData - スプレッドシートから取得したgroup mention IDと追加メンション対象ユーザーIDの配列
 * @param {string[]} groupMentionNames - ゲストからのチャット内で指定されたgroup mention nameの配列
 * @returns {string} - メンションすべきグループのIDsを文字列に変換したもの
 */
const _getGroupMentionText = (masterData, groupMentionNames) => {
  // マスターデータからnameで検索をかけてグループメンション情報を取得
  const targetRows = masterData.filter((row) =>
    groupMentionNames.includes(row[COLUMN_OF_MENTION_NAMES])
  );
  if (!targetRows.length) return "";

  // グループメンションIDをグループメンション(<!subteam^ID>)に変換
  return (
    targetRows
      .map((row) => "<!subteam^" + row[COLUMN_OF_MENTION_IDS] + ">")
      .join(" ") + "\n"
  );
};

export default async function (data, { MODULES }) {
  const { initLogger, secret } = MODULES;
  const logger = initLogger({ logLevel: "<% LOG_LEVEL %>" });

  // アプリ自身がインストールされているすべてのチャネルのメッセージで動作(slack側のevent subscribe設定で変更可能)
  const event = data.jsonPayload.data.hook_data.body.event;

  // 編集・削除時、bot自身の発言の場合、ファイルシェアのみでテキスト本文がない場合はスキップ
  if (
    (event.subtype &&
      (event.subtype === "message_deleted" ||
        event.subtype === "message_changed")) ||
    event.user === "<% SLACK_BOT_USER_ID %>" ||
    !Object.prototype.hasOwnProperty.call(event, 'blocks')
  )
    return;

  const { channel, ts, thread_ts, blocks } = event;
  const _ts = thread_ts || ts;

  // メンバー→ゲスト用:まずは正規表現でgroup mention idがあるかを確認する
  const groupMentionIds = _getGroupMentionIds(blocks);

  // ゲスト→メンバー用:@で始まるテキスト(ゲストからのグループメンション)があるかをチェック
  const groupMentionNames = _getGroupMentionNames(blocks);

  // メンバー、ゲストの両方からグループメンションがなければ終了
  if (!groupMentionIds.length && !groupMentionNames.length) return;

  // Googleスプレッドシートからgroup mention name,id,user idの対応データ(masterData)を取得
  const masterData = await _getMasterData(logger, secret);

  // グループorユーザーへのメンションに変換
  const chatText =
    _getGroupMentionText(masterData, groupMentionNames) +
    _getUserMentionText(masterData, groupMentionIds);
  if (!chatText) return;

  // 該当のチャンネルのスレッドにチャットを送信
  const result = await slack.chat.postMessage({
    text: chatText,
    channel,
    thread_ts: _ts,
  });
}

そこそこのボリュームがありますが、コメントを大量に入れているため、コードの詳細な説明は省きたいと思います。

大まかには、

  1. 受け取ったイベントデータを正規表現にかけ、「メンバーからのグループメンション」か「ゲストからのグループメンション」が含まれているかをチェック
    a. _getGroupMentionIds, _getGroupMentionNamesが処理を担当
  2. GoogleSpreadsheetからマスターデータ(グループとゲストの対応表)を取得
    a. _getMasterDataが処理を担当
  3. マスターデータと1で取得した文字列を照合し、返すべきメンションを文字列化して取得
    a. _getUserMentionText, _getGroupMentionTextが処理を担当
  4. Slackにメッセージを投下

という流れになります。

最初はevent.textから正規表現でグループメンションのIDやラベルとをっていたのですが、ゲストが「@all_」などを入力した時になぜか「<!subteam^SXXXXX|@all_>」などの正式なメンションと同じ文字列に変換されていたことがあったため、リッチテキストのtypeを見て分岐をかけるように変更しています。

環境変数周りの設定はCraft Functions独自のものとなっていますが、それ以外については通常のJavaScriptのため、Craft Funtions以外のFaaSでも問題なく利用は可能です。
※但しレスポンスが一定の秒数内に返らないとslack eventがリトライ処理を走らせるという仕様があるため、他のFaaSを利用する場合はレスポンスを時間内に返すよう意識する必要があります

FaaS側の設定

Craft Functionsではモジュールとして一部ライブラリの追加が可能なため、ありがたく使わせてもらっています。

slack-app-05.png

また、変数の設定は以下のようにしています。

slack-app-06.png

シークレットにしたいGoogleの認証情報については、同じくKARTE Craftのシークレットマネージャーという機能で定義しているため、そちらを参照するようにしています(上記コードのsecretsの部分です)。

またSlackアプリからイベントデータを送信するためのエンドポイントも必要になるため、設定から確認できるエンドポイントを控えておきます。

slack-app-07.png

GoogleSpreadsheet(マスターデータ)の設定

slack-app-08.png

利用したいグループメンションの情報を追加します。

メンバー→ゲストの場合はB列とC列が、ゲスト→メンバーの場合はA列とB列が必須になってきます。

そのため、ゲスト→メンバーでしか利用しない場合はC列の記載は不要です。

この列数は上記コードのCOLUMN_OF_MENTION_NAMESなどの定数で定義しています。

サービスアカウント(認証情報)については GoogleCloudのドキュメント を参照ください。

Slackの設定

Slackアプリの作成方法については公式のドキュメントなどが充実しているため、詳細はそちらを参照してください。

設定としては以下になります。

slack-app-09.png

今回はBotとして利用したかったため、トークンはOAuth & Permissionsから取得できる「Bot User OAuth Token」を利用しています。
また、Scopesはメッセージを送信するためbotに対してchat:writeの追加は必須です。channels:historyとgroups:historyについては当時のノリで追加したため、必須かは未検証です(是非お手元でご確認ください・・・!)。

動作のトリガーをアプリへのメンション時のみに絞りたい場合は、app_mentions:readの追加も必要です。

あとはevent subscriptionsからRequest URLとトリガーにしたいイベント(今回はmessage.channels)を追加してSlackアプリを利用したいワークスペースにインストールし、利用したいチャンネルの設定からアプリを追加すれば利用が可能になります。

以上が必要なコード・設定になります。

上記のコードや設定を元にすればCraft Functions以外のFaaSでも、似たようなことは可能ですので是非参考にしてみてください。

また、未検証ではありますがSlackアプリにはCustom functionsなるものがあるようで、これを使えばFaaSを利用しなくとも実現ができるかもしれません(そもそも簡単なレスをしたいだけならSlackのカスタムレスポンスで解決可能説もあります)。

基本思想

ではここからは、補足的に背景などの思考の部分を公開していきたいと思います。

今回のアプリは日常使いするものであり求められるのはシンプルな動きだったため、IFをSlackに寄せることでとにかく運用の負荷を感じさせないことを意識しています。

また重視すべきは人のリソース>アプリのコストという判断をし、アプリの起動も全てのメッセージに対して購読するようにしています。

例えばapp_mentionに範囲を絞ればCraft Functionsの起動回数(=コスト)はかなり抑えられますが、ゲスト→メンバーのグループメンションの際に一々Slackアプリにもメンションをしないといけず、運用の手間が発生してしまうため、コストに目を瞑って全メッセージで動作する設定としているということです。

技術選定

利用しているツール等の選定理由は以下になります。

対象 ツール名 理由
FaaS Craft Functions オンボーディングの一環でグループ会社の基盤であるKARTEのサービスに触れておきたかったため
DB GoogleSpreadsheet リテラシーが不要で誰でも気軽に更新ができる(運用が回りやすい)ため
ライブラリ @slack/web-api KARTE Craftのhttps://craft.developers.karte.io/craft-functions/modules/npm-modules/があり、@slack/boltが利用できないため
また、@slack/boltは3秒以内にレスポンスを返す必要があり、遅延時にリスクがあるため

今回は必要な時だけに動作してくれれば良い性質のアプリであるため、コストを抑えやすいFaaSを採用しています。

なぜ作ったのか(Core・Why)

今回はお勉強という目的があったので作る前提ではありましたが、少なくとも誰のどんな悩みを解決するのかなどは考えるべきだと思い以下をまとめていました。

個人的な経験から、プロダクトを作るときは常に「本当にそれが必要か」「期待通りに刺さるのか」を考えないといけないと自戒しているからです。

尚、一連の思考には「プロダクトマネジメントのすべて」に記載のあるプロダクトの4階層の考えを流用しています。What,Howは上述の内容で代替としています。

社内の文化としてはもっとライトに作ってみてひとまず当ててみるという進め方でも問題なかったのですが、プロダクトマネジメントの思考を当ててみたかったため今回はやや厚めに整理しました。

Core

今回はシンプルなツール開発だったので、Coreについてはこのツール単品というよりは、今CEとして意識したいことという文脈も含めて整理しています。

ミッション

生産性(量)と品質(質)を最大化し、組織と事業に貢献する

当社はスタートアップなので成長速度が速く、事業に人的リソースが追いついていない状況が今後くる可能性があります。今からそこに備えておくため、少しでも効率化を進めます。

ビジョン

ツールの力を使い大事なことに思考を割けるようにする

人のリソースには限界があるうえ、コンテキストスイッチが発生すると生産性が落ちるため、細かくて余計なことを考えないで済むよう、業務の本題から外れる部分は積極的にツールを導入・開発し解決します。

戦略

メンションを自動化して余計な思考リソースを削る

ビジョンの文脈で、「dev_allにインターン生入ってないから追加しなきゃ」等の思考を削り、何も考えずにメンションできるようにすることで無駄を省きます。
また、副次的効果としてゲストの疎外感も防止できるため、チーム意識の醸成にも微力ながら良い影響が出るという仮説も立てています。

以上のCoreを簡単にまとめると、今回のSlackアプリに関しては、「ゲスト絡みのメンションを考える思考リソースの無駄を省き、可処分時間を少しでも伸ばす」という世界観で進めている、ということになります。

Why

ここからは誰のために、なぜ作るのか、そもそも作る必要はあるのかを簡単に記載していきます。
今回は作る前提ではあったものの、判断の妥当性の評価は欠かせないためです。

誰をどんな状態にするのか

メンバー(ex. 社員)とゲスト(ex. インターン生)の、複数人が絡む状態で連絡を取るのが面倒だという悩みを解決する

今までの運用では、ゲストには常に「Devメンバーって誰がいたっけ?」をいちいち調べて、あるいは覚えて個別にメンションする必要がありました。
また、メンバーも全員に対してメンションしたい時にゲストが入らないため、個別にメンションを追加する必要がありました。

1回のロスは数秒かもしれませんが、意外とグループ単位でメンションすることは多く、その度に無駄な時間と思考が発生してしまう悩みがあるため、これを解決します。

なぜ作るのか(社内に十分なニーズはあるのか)

日常のコミュニケーションで利用されるものでありゲストは常に存在しているため、このアプリをも常にニーズがある状態となる

まず、数は多くないとはいえSlackにおけるゲストとしてインターン生や業務委託メンバーが稼働しており、今後完全にこの人数が0になる可能性は低いと考えられます。

また、この契約形態を変えることやSlackにおけるゲストの運用を変えることも困難です。

そのため、このアプリは常に一定のニーズを満たすものと考えられますし、現に欲しいという社内の声が以前から発生していたため十分な顕在化したニーズがあると判断しました。

なぜ作るのか(代替手段で回避できないのか)

主にメンテナンス性の観点から、Slackアプリとして開発する方が優位性があると考える

Slackの機能であるカスタムレスポンスを利用すれば、似たようなことを低コストで実装は可能です。
但し、この場合「指定した文字列に一致した場合固定の文字列を返す」という動きとなるため、

  • ユーザーグループの増減があった場合に、見るべき箇所(カスタムレスポンスの設定)が増える
  • ゲスト→メンバーとメンバー→ゲストで挙動を分けられず気持ち悪さが残る
  • カスタムレスポンスは他にも利用機会が多く、設定を後から探しにいくのが面倒になる可能性がある
  • お勉強のためにどうしてもCraft Functionsを利用して作りたい(これが最大の理由)

などの理由から、こじつけではあるのですがSlackアプリとして作るのが良いと判断しました。

結果と学び

2回に分けたリリースのうち、1回目のリリースにはなりますが全体メンションの反響は以下でした。

社員数は現段階では40名もおらず社内の距離が近く反応を得やすいという点を差し引いても、かなりの割合でリアクションをもらえていることから、このアプリの必要性が伺えます。

この記事を執筆している本日も本アプリは元気に活動を続けており、些細な部分ではありますが期待通りに無駄な思考リソースを削減できたと言って良さそうです。

slack-app-10.png

今回の開発から得た学びは主に以下になります。

技術面

  • Slack APIを使えば簡単な動きのアプリであればすぐに実装できる(今回コーディングに要したリソースはキャッチアップ・リファクタリングも含めて2人日もない)
  • SlackのAPIを利用してメッセージを送るときは、アプリ自身の発言の場合はreturnしておくなどの処理を入れる必要がある
    • でなければ無限ループに陥る可能性がある(1回やらかして心臓が止まるかと思いました)
  • FaaS側の癖の理解の方がどちらかというと時間がかかる
    • Craft Functionsはコーディングは比較的しやすいが、環境変数周りの動きが独特のためここで躓きやすい

意識面

  • 事後承認スタイルで進められるとスピード感がかなりあり、とても快適
    • しかし質を下げて良いということではないため、周囲の巻き込みなどが重要になる
  • 細かい機能やアプリであっても、事前にWhyの定義などを済ませておくことでイメージが固まるため、結果的に高品質なものを高スピードで作れるようになる
    • とはいえ社内文化としてはもっとライトに進めることもできるため、次回はアジャイルに進めることも検討してみたい
  • オンボーディング期間で社内の課題を見つけサクッと解決しておくと、状況の把握ができる上信頼も得やすくなるため一石二鳥

今回作成したアプリに関しては以上で終わりになりますが、このような試みは一定の効果を発揮することがわかった上、継続しないと意味がないため今後も隙間を見つけて同じような社内アプリや仕組みの開発は続けようかと考えています。

次回はSlackとNotion(と必要に応じてLLM)を組み合わせて社内KCSを作ろうかと画策しているため、そちらがうまくいったらまた筆を取ろうかと思います。

さいごに

かなりの長文になってしまいましたが、最後までお読みくださりありがとうございました。
この記事が少しでもあなたの助けになったようであれば幸いです。

もし、この記事を通して当社について気になる方がいらっしゃれば、ぜひカジュアルにお話ししましょう!