Inside of LOVOT

GROOVE X 技術ブログ

Slackマルチワークスペースでgroup mentionを使う話

こんにちは、GROOVE X Advent Calendar 2023 9日目の記事です。
普段はスクラムマスターをしている niwano が、久しぶりにプログラムコードを書いことを紹介します。
プログラミング楽しい♫ 。

きっかけ

Slack user groupの仕様が想定と違った

2022年の春に弊社は、Slack を EnterpriseGrid版に移行しました。
ワークスペースを組織単位でいくつも作れることや、Slack connectが無料とのことで、メリットを感じて採用したのですが、EnterpiseGrid版 にしてがっかりだった仕様の第1位が、user groupのスコープがワークスペースの範囲だということです。

どういうことかというと、以下のようなイメージです。 EnterpriseGridになると、Organaizationという概念がうまれて、その中に複数のワークスペースを作成できます。
チーム単位だったり、組織単位だったり、大きなチャンネルグループのイメージです。
弊社では、基本、1ユーザーの所属は1つのワークスペースになるように管理されています。

Slack マルチワークスペースの概要図

user groupがワークスペースの中で閉じているので、ワークスペースにまたがるメンバーを同じグループにいれたり、異なるワークスペースの user groupを呼び出したりすることができません。
おまけに、異なるワークスペースの user group はマスクされてしまう。

マスクされるというのはこんな感じです。
トーフっぽい。(トーフで年齢がばれますね!)

マルチワークスペースのgroup mention

以前はマスクされてなかったんですけどね....
設定で変えられるようになるのを期待してます!Slackさんお願いします!

自作している人をみつけた

インターネット上では同じように困っている人がいて、自作していたので、エンジニア魂に火がついたわたしは、「自分でつくってみるかー」となりました。

仕様はこんな感じ "GroupMentionBot"

メッセージ上のグループメンションを検出して、
他のワークスペースに同じグループ名が存在したら、そのグループ名をリプライします。
その時に、メッセージの抜粋をちょっとつけます。
その名も、"GroupMentionBot" です。

こんなイメージです。 <画像はる>

作ってみる

システム構成

最初はGAS上で動かしていましたが、postMessageのACKを5秒以内(今の仕様は分からない)に返さないと、Slackが再通知を行う制限に頻繁にかかり、おなじリプライが何個も発生する...みたいなことが置きてました。
(これもやってみないとわからないですよね〜)

ですので、今は大好きなGO言語でcloud Pub/Sub と Cloud Functionsで実装しています。

システム構成図

Slack appから EventSubscriptionsで、メッセージイベントを一度、Cloud Functionsで受け取ります。ACKを返したあと、Pub/Subのキューにメッセージを入力することで、前述のタイムアウトを回避します。
Pub/Subから取り出す側のCloud Functionsで、メッセージ内のgroupメンションを解析し、リプライメッセージを postMessage( ) します

ポイント

作成するにあたっての、ポイントは3点でした。
(最初からこの形になったのではなく、試行錯誤をしてこういう形になったんです〜)

  • Slack connectにも対応したい
  • なるべくメンテナンスレスにしたい
  • リプライにはメッセージの抜粋をつける

Slack connectにも対応したい

弊社は、Slack connectをつかって沢山の外部ワークスペースの人とコラボレーションをしています。

その方々とも、あたかも、同じ user group を使っているような動きがほしかったのと、その方々からも、グループメンションで呼び出されたい!

前者はスプレッドシートにて、個別IDをメンション候補に追加することで対応しています。
シートの内容は以下のような感じです。

シートのサンプル(IDは架空のものです)

合致する user group があった場合には、members id に定義された個別IDをリプライします。

個別IDサンプル(group名は架空のものです)

後者は、平文での user groupの入力にもGroupMentionbotがリプライができるようにしました。

group名、handle名は架空のものです

なるべくメンテナンスレスにしたい

最初は、GroupMentionBotが検出する user group をスプレッドシートに記載していたのですが、追加の要望が頻繁にあるので、検出用のシートは廃止し、user groupの接頭置で実施するかどうかを判断することにしました。

弊社では、group 名のあたまに、 * @group * @team がついている user group にのみ、GroupMentionBotが反応します。

リプライにはメッセージの抜粋をつける

当初は GroupMentionBotからのリプライは、group mentionのみの味気ないものでした。
この方法でも通知はできるのですが、Slack の Activityリストにはメッセージのない GroupMentionBotからの通知が貯まるんですね。

過去のリプライ(group名、handle名は架空のものです)

そこで、GroupMentionBotからの通知に本メッセージの文章を先頭から3行抜粋することにしました。 また、抜粋しているメッセージの中に user group が含まれると二重で通知されることになるので、user group を削除する処理をつけてます。

こんな感じです。

リプライ時に3行を表示する & group mentionを消す

ところが... rate limit にかかる

ある時、メンバーから、BOTが動いてないみたいです!という連絡が。
Cloud Functionsのログをみてみると...

slack rate limit exceeded, retry after 30s

と出てました。むむ.... リプライする user group を決定するために、usergoup.listのAPIを使っているのですが、このAPIが、Tier2 という定義で、毎分20リクエストに制限がかかっていました。

よくよく処理をみると、全てのメッセージに対して、不要に usergroup.list をコールする処理になっていました。
メッセージのなかに、user group があったときにのみ usergroup.list をコールして処理すればいいですし、なんなら、Pub/Sub のまえにチェックしたらいいので、チェック処理の場所を変えて対応しました。

システム構成図 update 版

でも、心配なので、Tier3 に変えて欲しい気もする...。 (そんなことできるのかな)

1分に20個の group mention しか処理できないです!笑

まとめ

いかがでしたでしょうか!
みなさんの Slack 生活の参考になれば幸いです!

おまけ

slackメッセージの中の group mentionを抽出する処理サンプル

package p

import (
    "regexp"
)

// Find and extract '@group_xxxx' and '@team_yyyy' from the message,
// capturing @group_xxxx and @team_yyyy.
func extractGroupID(text string) (groupIDs []GroupID) {
    // <!subteam^AAAA|@BBBB> があったら検出しないように消す
    subRegex, err := regexp.Compile(`<!subteam\^[A-Z0-9]+\|@[^\>]+>`)
    if err != nil {
        return nil
    }
    text = subRegex.ReplaceAllString(text, "")

    regex, err := regexp.Compile(`@(group_[^\s>]+|team_[^\s>]+)([ >\n]|$)`)
    if err != nil {
        return nil
    }
    matches := regex.FindAllStringSubmatch(text, -1)
    for _, match := range matches {
        if len(match) > 1 {
            groupID := GroupID{
                handleName: "@" + match[1],
                subTeamID:  "",
            }
            groupIDs = append(groupIDs, groupID)
        }
    }
    return groupIDs
}

// Find and extract <!subteam^AAAAAA|@bbbbbb>
// capturing @bbbbbb
func extractSubTeam(text string) (groupIDs []GroupID) {
    regex, err := regexp.Compile(`<!subteam\^([A-Z0-9]+)\|@(group_[^\>]+|team_[^\>]+)>`)
    if err != nil {
        return nil
    }
    matches := regex.FindAllStringSubmatch(text, -1)
    for _, match := range matches {
        if len(match) > 2 {
            groupID := GroupID{
                handleName: "@" + match[2],
                subTeamID:  match[1],
            }
            groupIDs = append(groupIDs, groupID)
        }
    }
    return groupIDs
}

func extractGroupIDAndSubTeam(text string) []GroupID {
    groupIDs := extractGroupID(text)
    groupIDs = append(groupIDs, extractSubTeam(text)...)
    return groupIDs
}