Slackの会話履歴の取得をTypeScriptで実装する ~conversations.replies~

# 目次

1. 概要と前提
1. 実装のポイント
    2.1. [`conversations.replies`](https://api.slack.com/methods/conversations.replies)のスコープ
    2.2. [`curosr`ベースのページネーション](https://api.slack.com/docs/pagination#cursors)
    2.3. [Rate limits](https://api.slack.com/docs/rate-limits)
1. ソースコード
1. 感想

# 1. 概要と前提

Slackのメッセージの会話履歴の取得をTypeScriptで実装していきます。

本記事では、私が実装する上で躓いたところを中心に解説していきます。そのため、全ての手順を詳細に解説するわけではありません。上記の目次を参考に、必要な箇所だけつまみ食いしていただければ幸いです。

**注意**：そもそも、Slackには、会話履歴などのデータをexportする機能が備わっています。
参考：[ワークスペースのデータをエクスポートする](https://slack.com/intl/ja-jp/help/articles/201658943-%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E3%82%A8%E3%82%AF%E3%82%B9%E3%83%9D%E3%83%BC%E3%83%88%E3%81%99%E3%82%8B)

そのため、以下の条件に当てはまらない方は、遠回りをすることになってしまうかもしれないのでご注意ください。

- Slackのexportを使えない
- Slackのexportを使いたくない
- Slack APIの実装力をつけたい
- TypeScriptで[conversations.replies](https://api.slack.com/methods/conversations.replies)の`cursor`を指定して[ページネーション](https://api.slack.com/docs/pagination#cursors)する具体的な実装を知りたい
などなど。

# 2. 実装のポイント

実装の基本的な流れとしては、以下の4ステップです。

1. 使用する変数の準備（チャンネルID、期間、など）
1. [conversations.history](https://api.slack.com/methods/conversations.history)でメッセージとそのメタデータなどを取得
1. [conversations.replies](https://api.slack.com/methods/conversations.replies)でメッセージのリプライを取得
1. 好きな形に加工（今回は、会話履歴とリンクのオブジェクトにします）
(ソースコードは本記事の最後に記載します)

この中で、特に[conversations.replies](https://api.slack.com/methods/conversations.replies)の扱いについて苦労しました。

そこで、今回は、[conversations.repliesのドキュメント](https://api.slack.com/methods/conversations.replies)に補足する形で実装する上で躓いた3つのポイントを解説していきます。

## 2.1. [`conversations.replies`](https://api.slack.com/methods/conversations.replies)のスコープ

まず、`conversations.replies`を使用するためのスコープとして、以下のいずれか一つ以上のスコープが必要です。

- [`channels:history`](https://api.slack.com/scopes/channels:history)：パブリックチャンネルの履歴取得に必要
- [`groups:history`](https://api.slack.com/scopes/groups:history)：プライベートチャンネルの履歴取得に必要
- [`im:history`](https://api.slack.com/scopes/im:history)：1対1のDMの履歴取得に必要
- [`mpim:history`](https://api.slack.com/scopes/mpim:history)：複数人のDMの履歴取得に必要

今回は、パブリックチャンネルの会話履歴取得を行いたいため、[`channels:history`](https://api.slack.com/scopes/channels:history)のみ設定します。（ユーザIDをユーザ名に置き換えたいなど、他に行いたい実装があれば、そのためのスコープを設定してください）

## 2.2. [`curosr`ベースのページネーション](https://api.slack.com/docs/pagination#cursors)

Slack APIでは、`cursor`ベースのページネーションによって、大量のデータを効率的に取得することができます。具体的には、大量のデータをいくつかのページに分割し、1ページごとに通信を行います。

`cursor`の詳細な挙動に関しては、[ドキュメント](https://api.slack.com/docs/pagination)を参照ください。今回は、ページネーションを実装する上で押さえておくべきポイントをステップバイステップで解説してみます。

1. ページネーションを行いたいメソッド（今回は`conversations.history`と`conversations.replies`）に、以下2つの引数を与える
    - `limit`：1ページ当たりのデータの数(100~200の間の数値が推奨されている)
    - `cursor`：次のページの有無
1. レスポンスのトップレベルに存在する`has_more`を確認する
1. `hes_more`が`true`の時、同じくレスポンスのトップレベルに存在する`response_metadata`に`next_cursor`というハッシュ値が存在する。この`next_cursor`を、2ページ目以降のリクエストから`cursor`にセットする。
1. `hes_more`が`false`になるまでリクエストをループさせる

以上の手順でページネーションを実装することができます。

注意点として、[ドキュメント](https://api.slack.com/docs/pagination)では、`next_cursor`は、emptyかnullかnon-existentと記述されており、ドキュメントの例では、基本的に空文字列(`""`)として扱われています。そのため、私は最初、`cursor`を`string`型として扱っていました。しかし、私の環境では、`response_metadata`に`next_cursor`が存在しなかったため、`cursor`を`string | undefined`型として扱う必要がありました。もしかしたら、バージョンによって型が変わるのかもしれません。注意してください。

## 2.3. [Rate limits](https://api.slack.com/docs/rate-limits)

次に、[SlackのRate limits](https://api.slack.com/docs/rate-limits)について説明します。

私が、このRate limitsを知らずに実装を行っていたとき、実際にコードを実行すると、以下のような警告文が出力され、一向に処理が完了しませんでした。

```
[INFO]  web-api:WebClient:0 API Call failed due to rate limiting. Will retry in 10 seconds.
[WARN]  web-api:WebClient:0 http request failed A rate limit was exceeded (url: conversations.replies, retry-after: 10)
```

それもそのはずで、Slack API（というか、多くのWeb API）には、[Rate limits](https://api.slack.com/docs/rate-limits)という、リクエストの制限数が存在します。

私は最初、何のことやらさっぱりだったのですが、[conversations.repliesのドキュメントのFacts](httpsのhttps://api.slack.com/methods/conversations.replies#facts)にも、きちんと記載されていました。このドキュメントによると、`conversations.replies`には、Slackの規格でいう[Tier 3](https://api.slack.com/docs/rate-limits#tier_t3)にあたるRate limitsが設定されています。具体的には、1分あたり50リクエストまでしか送信できません。短時間のリクエスト増加なら一定の範囲で許容されるらしいのですが、今回は、過去数年分の会話履歴を取得するため、がっつりRate limitsに引っかかりました。

そこで、今回は、1リクエスト当たり1000ミリ秒のスリープを行うことで解決しました。

（もっとスマートな解決方法をご存じの方、コメントでご教示いただけると恐悦至極でございます。）

# 3. ソースコード 

以上のポイントを踏まえて私が実装したソースコードがこちらになります。

ご査収ください。

```TypeScript
import { WebClient } from '@slack/web-api';
import { MessageElement } from '@slack/web-api/dist/response/ConversationsHistoryResponse.js';
import dotenv from 'dotenv';
dotenv.config({ path: `.env.${process.env.NODE_ENV}` });

const sleep = (ms: number) => {
    return new Promise((resolve) => setTimeout(resolve, ms));
};

export const fetchSlackHistoryDocument = async (channelId: string, fromPeriodInMonths: number) => {
    try {
        if (fromPeriodInMonths <= 0 || !Number.isInteger(fromPeriodInMonths)) {
            throw new Error('fromPeriodInMonthsには、正の整数を入力してください。');
        }
        const startDate = new Date();
        startDate.setMonth(startDate.getMonth() - fromPeriodInMonths);
        // UNIX時間に変換(WebClientの引数)
        const oldest = Math.floor(startDate.getTime() / 1000);

        const webClient = new WebClient(process.env.SLACK_OAUTH_TOKEN);

        const messages: MessageElement[] = [];
        let historyCursor: string | undefined = undefined;
        let replyCursor: string | undefined = undefined;

        // 会話履歴の取得
        do {
            const histories = await webClient.conversations.history({
                channel: channelId,
                oldest: oldest.toString(), // 期間(to)
                limit: 100, //1ページ当たりの上限
                cursor: historyCursor, // cursorが存在する時、ページネーションする
            });
            if (!histories.ok) throw new Error('conversations.history is error');
            if (!histories.messages) break;

            for (const message of histories.messages) {
                if (message.thread_ts) {
                    do {
                        // conversations.repliesのreta limitが50/minのため
                        await sleep(1000);
                        const replies = await webClient.conversations.replies({
                            channel: channelId,
                            ts: message.thread_ts,
                            limit: 100,
                            cursor: replyCursor,
                        });
                        if (!replies.ok) throw new Error('conversations.replies is error');
                        if (!replies.messages) break;

                        messages.push(...replies.messages);
                        if (replies.has_more) {
                            console.log(
                                replies.response_metadata,
                                replies.response_metadata?.next_cursor
                            );
                        }

                        replyCursor = replies.has_more
                            ? replies.response_metadata?.next_cursor
                            : undefined;
                    } while (replyCursor);
                } else {
                    messages.push(message);
                }
            }

            historyCursor = histories.has_more
                ? histories.response_metadata?.next_cursor
                : undefined;
        } while (historyCursor);

        // 好きな形に加工する
        const slackHistories = messages
            .filter((message) => message.text)
            .map((message) => {
                return {
                    pageContent: message.text ?? '',
                    url: `https://${
                        process.env.WORKSPACE_NAME
                    }.slack.com/archives/${channelId}/p${message.ts?.replace('.', '')}?thread_ts=${
                        message.thread_ts
                    }&cid=${channelId}`,
                };
            });

        return slackHistories;
    } catch (err) {
        console.error(err);
        return null;
    }
};
```

# 4. 感想
今年の4月に今の会社に入社して、初めて今風のWeb開発を経験しています。

スコープなどの権限管理、一般的なWeb APIの仕様、ライブラリの扱い、などなど、初めてのことだらけでなかなか右往左往しています。しかし、「なんかよくわからんけど実装できました」だと、会社にとっても自分にとってもよくないため、ドキュメントをいっぱい調べながら開発しています。その中で調べたドキュメントのリンクをこの記事にも色々貼らせていただきました。

私が調べた知識が、同じ新人エンジニアの方々の助けになれば幸いです。