🕺

Slack APIを使ってムードメーカーを可視化してみた

2023/05/15に公開

はじめに

こんにちは。IVRyでバックエンドエンジニアをしている小瀬といいます。
https://ivry.jp/
初っ端から宣伝ですが、IVRyではバックエンド、フロントエンド、AIエンジニアなど、幅広く募集しておりますのでご興味ございましたらぜひご連絡ください!
https://ivry-jp.notion.site/IVRy-e1d47e4a79ba4f9d8a891fc938e02271

ムードメーカーと良く呼ばれる

僕はよくみんなに「ムードメーカー」と呼ばれます。
ムードメーカーといえば、「みんなを笑顔にする人」というイメージがありますよね?

IVRyでは、社内のエンジニアが作ったシステムを利用して、Slack上で笑いを取った数を人単位で集計しているのですが
https://zenn.dev/ivry/articles/60a97aae9166f0

集計した笑いを取った数はいつも社長の奥西に負けています

確かに奥西はおもしろいですが、「ムードメーカー」とは呼ばれてません。

そう悩んでる時に、僕はは気づきました。

ムードメーカーとは、ゴール(笑い)を決める人ではなく、ゴールのアシストをする人

なのだと。(完全仮説ですw)

この仮説が正だとすると、「笑いを取った一つ前の投稿」の数をアシストと仮定し、そのアシスト回数の数で誰がムードメーカーか決まるのではないか
と思いました。

笑いを取った前の投稿を取得する

そうと決まれば実装です。Slack apiを使って笑いのスタンプが押された投稿のひとつ前の投稿を取得します。

笑った投稿が集約されるreacjiチャンネルを利用

IVRyでは、Slackのリアク字チャンネラー機能を使って
https://slack.com/intl/ja-jp/help/articles/360000482666-Slack-用リアク字チャンネラー
笑いが起きた投稿を集約する「reacji-hot-channel」というチャンネルがあります。その様子は下記ブログにも書いています。
https://note.com/kose_atsuya/n/nacfe0be6288f

この、reacji-hot-channel には、笑いを取った投稿のURLが投稿されます。このチャンネルを利用して集計していきます。

ロジックをざっくり解説

笑いを取った投稿の一つ前の投稿を取得するロジックは下記です。

  1. reacji-hot-channel で笑いを取った投稿のURLを取得する
  2. 笑いを取った投稿の下記情報を取得する
    1. timestamp
    2. チャンネルID
    3. 投稿の種類(スレッド or チャンネル)
  3. 上記情報を使って再度チャンネルorスレッドの投稿履歴を取得する
  4. 笑いを取った投稿の一つ前の投稿の投稿者を取得する

上記のようなロジックです!

コード

コードは下記です。ムードメーカーを炙り出せるかどうかを検証することが目的であり、コードにはそこまで魂を込めてませんので、ご利用の際は適宜リファクタリングしてください。笑
スレッドとチャンネルでソートの順番が違う(ascとdescで逆だった)のに注意しながら実装しました。

from slack_sdk import WebClient
import re
import urllib

# Slack APIトークンを環境変数から取得する
TOKEN = 'xxxx'
SLACK_CLIENT = WebClient(token=TOKEN)

# 取得するチャンネルのIDを指定する
SUMMARY = {}
REACJI_HOT_CHANNEL = "C0xxxxxxx"


def gather_mood_maker():
    # reacji_hot チャンネルの投稿履歴を取得する
    # チャンネルの履歴を取得する
    latest_ts = None
    cnt = 0
    while cnt < 1:
        # チャンネル履歴を取得する(100件ずつ)
        response = SLACK_CLIENT.conversations_history(channel=REACJI_HOT_CHANNEL, latest=latest_ts, limit=100)
        messages = response['messages']
        # 最後のメッセージのtimestampをlatest_tsに設定して、次のAPI呼び出しで取得するメッセージの範囲を指定する
        for message in response["messages"]:
            link = message['text']  # racjiチャンネルは投稿内容がtextのためtextを渡すと、投稿元のリンクを渡すことになる
            find_mood_maker(link)
            # break
        if response["has_more"]:
            latest_ts = messages[-1]["ts"]
        else:
            break
        cnt += 1


def find_mood_maker(link):
    # 笑いが起きたIDとタイムスタンプの抽出
    match = re.search(r"archives/([^/]+)/p(\d+)", link)
    print(link)
    if not match:
        # racjiはurlが投稿されるが、urlじゃない投稿があった場合は集計対象にしない
        return
    channel_id, timestamp = match.groups()
    target_ts = timestamp[:10] + '.' + timestamp[10:].replace('>', '')  # timestampに小数点を入れる. FIXME: この処理いけてる方法募集
    thread_ts = None
    # スレッドが発生しているメッセージか判定
    if "thread_ts" in link:
        thread_ts = urllib.parse.parse_qs(urllib.parse.urlparse(link).query)['thread_ts'][0]  # 投稿内容のtimestampを取得

    if thread_ts is None or thread_ts == target_ts:
        # threadが発生していない、もしくはthread発生timestampと投稿内容timestampが同じ時はthread内でなく親の投稿と判定
        is_thread_message = False
        messages = get_channel_messages(channel_id, target_ts)
    else:
        is_thread_message = True
        messages = get_thread_messages(channel_id, thread_ts, target_ts)

    goal_message, assist_message = search_assist_message(messages, target_ts, is_thread_message)
    gather_summary(assist_message, goal_message)


def gather_summary(assist_message, goal_message):
    print('--------prev')
    print(assist_message['text'])
    if not assist_message:
        print('No Assist!!!')
        return
    if not assist_message.get('user'):
        print('BotなどがAssist')
        return
    assist_user = user_name_from_message(assist_message)
    goal_user = user_name_from_message(goal_message)
    SUMMARY.setdefault(assist_user, 0)
    SUMMARY[assist_user] += 1
    print('---summary---')
    print(sorted(SUMMARY.items(), key=lambda x: x[1], reverse=True))


def search_assist_message(messages, target_ts, is_thread_message):
    assist_message = None
    goal_message = None
    for i, message in enumerate(messages):
        # 笑いが起きた投稿を探してそのindexを取得する
        if str(message["ts"]) == target_ts:
            print(messages[i]["text"])
            goal_message = messages[i]
            if is_thread_message:
                # threadのmessageはindexが低いほど過去の投稿(asc)
                # 一つ前の投稿はi - 1 で取得できる
                assist_message = messages[i - 1]
            else:
                # channelのmessageはindexが低いほど最新の投稿(desc)
                # 一つ前の投稿はi + 1 で取得できる
                assist_message = messages[i + 1]
            break
    return goal_message, assist_message


def get_thread_messages(channel_id, thread_ts, target_ts):
    # threadのメッセージはindexが低いほど新しいメッセージ.
    # latestとinclusiveを指定して、笑いを取った投稿を含み、その投稿から過去分のメッセージを2件取得
    response = SLACK_CLIENT.conversations_replies(channel=channel_id, ts=str(thread_ts),
                                                  latest=str(float(target_ts)), inclusive=True, limit=2)
    messages = response["messages"]
    for message in messages:
        print(message['text'], message['ts'])
    return messages


def get_channel_messages(channel_id, target_ts):
    # チャンネルのメッセージはindexが低いほど古いメッセージ(threadの逆).
    # latestとinclusiveを指定して、笑いを取った投稿を含み、その投稿から過去分のメッセージを2件取得
    response = SLACK_CLIENT.conversations_history(channel=channel_id,
                                                  latest=str(float(target_ts)), inclusive=True, limit=2)
    messages = response["messages"]
    return messages


def user_name_from_message(message):
    if message.get('user') is None:
        return 'unknown'
    user = message['user']
    # print(client.users_info(user=user))
    user_name = SLACK_CLIENT.users_info(user=user)['user']['profile']['display_name']
    if user_name == '':
        user_name = SLACK_CLIENT.users_info(user=user)['user']['name']
    return user_name


if __name__ == '__main__':
    gather_mood_maker()

結果を出力

('Atsuya Kose', 24), 
('rokunishi', 16), 
('nakagawa', 6),
('Kent IZUMIYAMA', 6), 
('slackgpt', 4), 
...

見事!奥西(rokunishi)を抜いて1位になることができましたので、ムードメーカを炙り出すことができました!!!

疑惑をかけられる

社長から鋭い指摘が入りました。笑の数に対して集計対象の投稿100個というのは少ないと。

そして数を増やすと、奥西に負けそうだったので途中で打ち切りました。笑

アシストの条件を追加

なぜ、奥西に勝てないのか考えました。奥西は一人で笑いを取っているケースが多いです。つまり笑いを取った一つ前の投稿も同じ人が投稿していると言うことです。自分でアシストをしてゴールを決める人が、ムードメーカーと呼べるでしょうか? ムードメーカーというのは、みんなにゴールを与える人なのではないかと(?)

そこでコードを編集して、この「セルフアシスト」を除外するようにしました。goalを決めたユーザーとassistのユーザーが同じだったら集計しないだけです。

    SUMMARY.setdefault(assist_user, 0)
    SUMMARY_WITHOUT_SELF_ASSIST.setdefault(assist_user, 0)
    SUMMARY[assist_user] += 1
    if assist_user != goal_user:
        SUMMARY_WITHOUT_SELF_ASSIST[assist_user] += 1
    print('---summary---')
    print(sorted(SUMMARY.items(), key=lambda x: x[1], reverse=True))
    print(sorted(SUMMARY_WITHOUT_SELF_ASSIST.items(), key=lambda x: x[1], reverse=True))

真のムードメーカーは果たして...

100→1000笑いを対象に集計してみました。結果は

アシスト数
奥西: 167 小瀬: 123

アシスト数(セルフアシスト除く)
奥西: 93 小瀬: 95

となり、アシスト数では負けましたが、セルフアシスト除外のアシスト数では勝利しました!
これで、真のムードメーカーを炙り出すことができましたね!!(多分)

もちろんひとつ前の投稿がアシストになってないケースもあると思います。その確度を上げるために、timestampが離れていたら除外するなどやってみてもおもしろいかもしれません。(今回は試せてません)

ということで、ムードメーカーを炙り出したい時、また、Slackで何かの投稿の一つ前の投稿を取得したい時はぜひこちらのコードを参考にしてみてください!

IVRyテックブログ

Discussion