🎄

Slack Web API を使ってリアクション毎に一番貰った人を集計する

ボルドー2022/12/07に公開

株式会社 IVRy (アイブリー) 社員番号 7 番 エンジニアのボルドーです。
私は普段 IVRy ではフロントエンドとモバイルアプリの開発を担当しています。

今回は Slack Web API を使ったリアクションの集計方法について記載します。
経緯としては弊社代表の 奥西 から「策定した 3 つの Value の浸透促進のため、Slack でリアクション数を集計したい」という要望が上がっていたので、これはアドベントカレンダーのネタになるかなと日曜大工を引き受けました。

策定した Value についてはこちらの記事で紹介されています。



当時の Slack キャプチャ

実現したいこと & 方針

今回実現したいことは簡潔に書くとこのようになります。

Slack にて月毎に任意のリアクションをたくさん貰った人をランキングしたい。


本記事の最終成果物

ここで問題となるのが特定期間での絞り込みです。

Slack が提供している API(2022/12/6 時点) は大きく分けると

があります。
リアクション情報を取得することができるのは Web API と Events API のどちらかになるのですが、 Web API ではリアクションが追加された日時を取得することができません。

一方、Events API ではサーバーを常時起動させておく必要はあるものの、リアルタイムにリアクションの 追加 / 削除 イベントが取得可能です。

特定期間での絞り込みを厳密に行うためには Events API を使う必要がある のですが、今回は簡易的に集計がしたいだけなので そこは目をつぶって Web API を使う方針 で行きたいと思います。[1]
よって、方針としてはリアクションの追加日時ではなく、 リアクションが付与されている投稿の日時で判断する こととします。

Slack Web API でのリアクション集計パターン

Web API から実際にリアクションを集計する方法は メソッド一覧 を眺めた感じでは 2 パターンありそうでした。

1. conversations.history を利用して各チャンネルの投稿一覧を取得して各投稿のリアクションを集計する

そのためには対象となるチャンネル一覧を事前に取得する必要があります。
この方針で実装されている方は一定数いそうなので詳細は省きますが、スレッドも集計するためには各投稿にスレッドがあるかを thread_tsreply_count の有無で判定して追加取得してく必要があります。

2. reactions.list を利用して各ユーザーのリアクション一覧を取得して集計する

そのためには対象となるユーザー一覧を事前に取得する必要があります。
軽く調べた限りではこの方針で実装されている方は見当たりませんでした。
しかしながら、この方法であれば

  • スレッドのリアクションも簡単に取得できる
  • 一度のリクエストで 1000 件 取得できるのでリクエスト回数が減らせる
    • 弊社ではトピック毎にチャンネルが細かく分かれており、議論の際にスレッドを多用しているので 1 の方法ではリクエスト数がこの方法に比べて多くなります

ということで、この方法で正しく集計できるのかは不明ながら記事としては希少価値がありそうなのでこちらで実装してみようと思います。

実装

0. 事前準備

Slack Web API のクライアントが提供されているのでこちらを使用します。
こちらを利用することで使用制限が生じた際の処理を気にする必要がなくなります。[3][4]

yarn add @slack/web-api

今回は WebClient を 2 つ用意しています。
というのも、実際に Slack に投稿して色々確認する際に私の権限では GUI(Slack アプリ)で BOT の投稿を削除することができず、都度 BOT として削除 API を実行しないといけないため、開発では自分のユーザートークンを利用しています。
環境変数にそれぞれのトークンを設定しておきます。

slack/index.ts
import { WebClient } from '@slack/web-api';

export const userClient = new WebClient(process.env.SLACK_TOKEN);
export const botClient = new WebClient(process.env.SLACK_BOT_TOKEN);

1. 対象となるユーザー一覧を取得する

使用する API: users.list

ユーザー数が多いと一度で取得しきれないので cursor を使用して全件取得します。[5]
他の処理で名前等が必要となる場合はキャッシュしておきましょう。

slack/users.ts
import { UsersListArguments, UsersListResponse } from '@slack/web-api';
import { Member } from '@slack/web-api/dist/response/UsersListResponse';
import { botClient, userClient } from './index';
import { SlackDemoOptions } from '../../types';

export const getUsersList = async (
  args: UsersListArguments,
  options?: SlackDemoOptions
): Promise<UsersListResponse> => {
  if (options?.asBot) return botClient.users.list(args);
  return userClient.users.list(args);
};

export const getAllUsers = async (
  args: UsersListArguments,
  options?: SlackDemoOptions
): Promise<Member[]> => {
  const members = Array<Member>();
  let cursor: string | undefined;
  do {
    const res = await getUsersList({ ...args, cursor }, options);
    cursor = res.response_metadata?.next_cursor;
    members.push(...(res.members || []));
  } while (cursor);

  return members;
};

2. 対象ユーザー毎にリアクションした投稿の一覧を取得する

使用する API: reactions.list

指定したユーザーが行ったリアクションを全件取得します。
こちらも cursor を利用して全件取得します。ドキュメントには limit に 1000 まで指定できるが遅くなるので推奨していない旨の記載がありますが、制限に引っかかると 30 秒待つことになるので、デフォルトで 1000 件ずつ取得するようにしつつ、利用側で上書きできるようにしました。
指定する user は args として利用側から渡されます。

import { ReactionsListArguments, ReactionsListResponse } from '@slack/web-api';
import { Item } from '@slack/web-api/dist/response/ReactionsListResponse';
import { botClient, userClient } from './index';
import { SlackDemoOptions } from '../../types';
import { convertTsToDate, isWithinByDate } from '../../lib/helper';

export const getReactionsList = async (
  args: ReactionsListArguments,
  options?: SlackDemoOptions
): Promise<ReactionsListResponse> => {
  if (options?.asBot) return botClient.reactions.list(args);
  return userClient.reactions.list(args);
};

export const getAllReactedItems = async (
  args: ReactionsListArguments,
  options?: SlackDemoOptions
): Promise<Item[]> => {
  const items = Array<Item>();
  let cursor: string | undefined;
  do {
    const res = await getReactionsList(
      { limit: 1000, full: true, ...args, cursor },
      options
    );
    cursor = res.response_metadata?.next_cursor;
    // 期間が設定されていたらフィルターする
    const newItems =
      (options?.startDate || options?.endDate
        ? res.items?.filter((item) => {
            if (item.message?.ts) {
              return isWithinByDate(
                convertTsToDate(item.message.ts),
                options?.startDate,
                options?.endDate
              );
            }
            return false;
          })
        : res.items) || [];
    items.push(...newItems);

    // (1) 期間外の Item が一定数以上あるならそれ以上はリクエストしない
    if ((res.items || []).length - newItems.length > (args.limit ?? 400) / 2) {
      cursor = undefined;
    }
  } while (cursor);

  return items;
};

悩ましいのが (1) の部分でして、ユーザーによっては全件取得すると物凄い数になってしまうのですが、実際に利用するのは特定期間内の投稿へのリアクションなので一部分のみということもあり、何かしらの制限を設けたいと思いこのようにしました。
しかし、この方法では options?.endDate が十分過去の場合、おかしな挙動となってしまうので改良が必要です。

3. 集計する

1,2 で作成したメソッドを利用して得られた情報を集計します。
集計処理をもっとスマートに書きたいのですが、実力不足で読みづらくて申し訳ないです。
やっていることとしては

  • ユーザー毎に取得した Item[] を flatten したものは重複している可能性があるので排除する
    • 投稿 A に対してユーザー X と Y がリアクションした場合は X, Y それぞれの reactions.list で Item として投稿 A が返ってきます
  • 重複を排除した Item[] を走査して、与えられたユーザーリストの各ユーザーの投稿に対するリアクションを集計する
    • この時、 ::skin-tone-x は削除して同一のリアクションとして扱う
  • 最終的に投稿ユーザー毎に得られたリアクション名とカウントの辞書を作成して返す
import { Member } from '@slack/web-api/dist/response/UsersListResponse';
import { Item } from '@slack/web-api/dist/response/ReactionsListResponse';

interface ReactionDictionary {
  [id: string]: number;
}

interface MemberDictionary {
  [id: string]: ReactionDictionary;
}

/**
 * 与えられた items から与えられた members の投稿に対するリアクションを集計する
 * なお、`::skin-tone-x` は削除して集計する
 * @param {Item[]} items        重複を許容した集計対象となる Item の配列
 * @param {Member[]} members    集計対象となる Member の配列
 * @returns {MemberDictionary}  投稿したユーザーに対して追加されたリアクション名と回数の辞書
 *                              例) { 'UXXXXX': { '+1': 2, 'pray': 1 ... } }
 */
export const aggregateReactionsForEachMember = (
  items: Item[],
  members: Member[]
): MemberDictionary => {
  const skinToneRegex = /::skin-tone-\d/;
  const memberIds = members
    .map(({ id }) => id)
    .filter((id): id is string => typeof id == 'string');
  // 重複排除
  const itemMap = new Map(
    items.map((item) => [
      `${item.channel}/${item.type}/${item.message?.ts}/${item.message?.thread_ts}`,
      item,
    ])
  );
  const uniqueItems = [...new Map([...itemMap].sort()).values()];

  console.log(
    `items(${items.length}) - uniqueItems(${uniqueItems.length}) = ${
      items.length - uniqueItems.length
    } 件重複`
  );

  // 投稿者のIDと得られたリアクションの辞書
  const postedMemberIdToReactionDict: MemberDictionary = {};
  for (const id of memberIds) {
    postedMemberIdToReactionDict[id] = {};
  }

  for (const item of uniqueItems) {
    // user (投稿者のID)がないなら集計しない
    if (!item.message?.user) continue;
    switch (item.type) {
      case 'message':
        const mDict = postedMemberIdToReactionDict[item.message.user];
        // 指定されたメンバーの投稿以外は集計しない
        if (!mDict) continue;
        for (const reaction of item.message?.reactions ?? []) {
          // リアクション名が取れないなら集計しない
          if (!reaction.name) continue;
          // ::skin-tone-x は除外して集計する
          const rName = reaction.name.replace(skinToneRegex, '');
          mDict[rName] =
            (mDict[rName] ?? 0) +
            (reaction.count ?? reaction.users?.length ?? 0);
        }
        break;
      case 'file':
        // 現在の Slack App でこのケースはない?
        console.log('file: ', item);
        break;
      case 'file_comment':
        // 現在の Slack App でこのケースはない?
        console.log('file_comment: ', item);
        break;
      default:
        break;
    }
  }

  return postedMemberIdToReactionDict;
};

4. コマンドを解析して slack に投稿する

CLI として動作するようにコマンドを作成します。
今回は zenn-dev/zenn-cli を参考に作成しました。
構成はほとんどそのまま流用させていただいたので、詳細は省きますが、以下のようなコマンドを用意して、zenn-cli のように commands/index.ts から呼び出します。
楽をして以下のコマンド内で色々やっているため見づらいですが、ご容赦ください mm

import arg from 'arg';
import { invalidOptionText, aggregateReactionsHelpText } from '../lib/messages';
import { CliExecFn, SlackDemoOptions } from '../types';
import * as Log from '../lib/log';
import { retrieveAllUser } from '../api/user';
import { getAllReactedItems } from '../api/slack/reactions';
import { aggregateReactionsForEachMember } from '../api/reaction';
import { Item } from '@slack/web-api/dist/response/ReactionsListResponse';
import { postMessageToSlack } from '../api/slack/chat';

function parseArgs(argv?: string[]) {
  try {
    return arg(
      {
        // Types
        '--start-date': String,
        '--end-date': String,
        '--reactions': String,
        '--dry-run': Boolean,
        '--as-user': Boolean,
        '--help': Boolean,
        '--debug': Boolean,

        // Alias
        '-h': '--help',
      },
      { argv }
    );
  } catch (e: any) {
    if (e.code === 'ARG_UNKNOWN_OPTION') {
      Log.error(invalidOptionText);
    } else {
      Log.error(e);
    }
    Log.error(aggregateReactionsHelpText);
    return null;
  }
}

export const exec: CliExecFn = async (argv) => {
  const args = parseArgs(argv);
  if (args === null) return;

  if (args['--help']) {
    Log.success(aggregateReactionsHelpText);
    return;
  }

  const options: SlackDemoOptions = {
    asBot: args['--as-user'] === undefined ? true : !args['--as-user'],
    dryRun: args['--dry-run'],
    startDate: args['--start-date']
      ? new Date(args['--start-date'])
      : undefined,
    endDate: args['--end-date'] ? new Date(args['--end-date']) : undefined,
  };
  if (!options.endDate) options.endDate = new Date();
  if (!options.startDate) {
    options.startDate = options.endDate;
    options.startDate?.setMonth(options.endDate!.getMonth() - 1);
  }

  const targetReactions = args['--reactions']
    ? args['--reactions'].split(',')
    : ['to-be-oriented', 'feelspecial', 'simplify-x', '+1', 'pray'];

  // 対象ユーザーは bot と deleted 以外の全ユーザーとする
  const users = (await retrieveAllUser()).filter(
    (u) => !u.is_bot && !u.deleted
  );

  let items: Item[] = [];
  for (const member of users) {
    items.push(...(await getAllReactedItems({ user: member?.id }, options)));
  }
  Log.debug(`集計対象投稿数(重複含む): ${items.length}`);

  // リアクション毎にそのリアクションを貰ったユーザーとその回数をまとめる
  const reactionNameToCount = Object.entries(
    aggregateReactionsForEachMember(items, users)
  )
    .flatMap(([memberId, rDict]) =>
      Object.entries(rDict)
        .filter(([reactionName]) => targetReactions.includes(reactionName))
        .map(([reactionName, count]) => ({
          key: reactionName,
          memberId,
          count,
        }))
    )
    .reduce((acc, v) => {
      const elm = { mid: v.memberId, count: v.count };
      acc.set(v.key, acc.has(v.key) ? [...acc.get(v.key)!, elm] : [elm]);
      return acc;
    }, new Map<string, Array<{ mid: string; count: number }>>());

  // 整形
  const blocks: string[] = [];
  const keys = [...reactionNameToCount.keys()];
  for (const key of keys) {
    const list = reactionNameToCount
      .get(key)!
      .sort((a, b) => b.count - a.count)
      .slice(0, 5);
    if (list.length === 0) {
      blocks.push(`:${key}: を獲得した人はいませんでした。`);
      continue;
    }
    const text = `最も :${key}: を獲得したトップ${
      list.length
    }は、この人たちです!\n${list
      .map(({ mid, count }, index) => {
        const member = users.find((m) => m.id === mid);
        return `${index + 1}. <@${mid}> (${count})`;
      })
      .join('\n')}`;
    blocks.push(text);
  }
  blocks.push(
    ...targetReactions
      .filter((r) => ![...keys].includes(r))
      .map((r) => `:${r}: を獲得した人はいませんでした。`)
  );

  // アウトプット
  if (args['--dry-run']) {
    Log.success(blocks);
  } else {
    await postMessageToSlack(
      {
        channel: args['--debug'] ? 'C0XXXXXX' : 'C0YYYYYY',
        blocks: [
          `${options.startDate?.toLocaleDateString()}~${options.endDate?.toLocaleDateString()}の期間で最もリアクションを貰った人を表彰します🎉`,
          ...blocks,
        ].flatMap((b) => [
          { type: 'divider' },
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: b,
            },
          },
        ]),
      },
      options
    );
  }
};

5. 実行してみる

それでは実行してみます。
今回は commands/index.ts には aggregate:reactions と設定しています(ユーザー ID は一律で UXXXXXXX へと置換しています)。

yarn slack-demo aggregate:reactions --start-date '2022-12-01T00:00:00' --end-date '2022-12-06T00:00:00'  --debug

# ---
yarn run v1.22.18
$ node dist/slack-demo.js aggregate:reactions --start-date 2022-12-01T00:00:00 --end-date 2022-12-06T00:00:00 --dry-run --debug --as-user
debug: added 0 items for Slackbot (USLACKBOT)
debug: added 85 items for Ryoga Okunishi / 奥西 亮賀 / おくにし りょうが (UXXXXXXX)
.
.
.
debug: 集計対象投稿数(重複含む): 2348
debug: items(2348) - uniqueItems(1436) = 912件重複
success: [
  '最も :+1: を獲得したトップ5は、この人たちです!\n' +
    '1. <@UXXXXXXX> (45)\n' +
    '2. <@UXXXXXXX> (27)\n' +
    '3. <@UXXXXXXX> (24)\n' +
    '4. <@UXXXXXXX> (23)\n' +
    '5. <@UXXXXXXX> (21)',
  '最も :pray: を獲得したトップ5は、この人たちです!\n' +
    '1. <@UXXXXXXX> (32)\n' +
    '2. <@UXXXXXXX> (21)\n' +
    '3. <@UXXXXXXX> (21)\n' +
    '4. <@UXXXXXXX> (15)\n' +
    '5. <@UXXXXXXX> (9)',
  '最も :feelspecial: を獲得したトップ5は、この人たちです!\n' +
    '1. <@UXXXXXXX> (3)\n' +
    '2. <@UXXXXXXX> (2)\n' +
    '3. <@UXXXXXXX> (1)\n' +
    '4. <@UXXXXXXX> (1)\n' +
    '5. <@UXXXXXXX> (1)',
  '最も :to-be-oriented: を獲得したトップ1は、この人たちです!\n1. <@UXXXXXXX> (1)',
  ':simplify-x: を獲得した人はいませんでした。',
]
✨  Done in 319.85s.

実際に Slack に投稿してみた結果はこんな感じです。


以上、reactions.list を利用した場合のリアクション集計実装紹介でした。
ご覧いただきありがとうございました。

We are hiring!!

最後に、IVRy では一緒に働く仲間を絶賛募集中です。
今の所 順調に成長してきていますが、今後の更なる成長のためには圧倒的に仲間が不足しています。皆さまのご応募お待ちしております!

カジュアルに話を聞きたいという方は私の Meety から面談を申し込んでいただければ色々お話します。

代表の奥西とも話せます!

脚注
  1. スプレッドシートを Web API 化する方法もあるようですが、制限があったり、集計を GAS で行うもしくは 集計時に CSV 出力して別途集計処理をする必要がありそうでしたので今回は見送りました。 ↩︎

  2. BOT は convesations.join が使えない?ようなので、私が一度 conversations.join で全てのパブリックチャンネルに入ってから BOT を conversations.invite で追加しました。 ↩︎

  3. https://slack.dev/node-slack-sdk/web-api#automatic-retries ↩︎

  4. https://slack.dev/node-slack-sdk/web-api#rate-limits ↩︎

  5. https://slack.dev/node-slack-sdk/web-api#pagination にて別の方法も紹介されています。 ↩︎

IVRyテックブログ

電話DXのIVRy(アイブリー)を開発しているエンジニアのテックブログまとめです。

Discussion

ログインするとコメントできます