5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SlackAdvent Calendar 2023

Day 7

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

Last updated at Posted at 2023-12-06

目次

  1. 概要と前提
  2. 実装のポイント
    2.1. conversations.repliesのスコープ
    2.2. curosrベースのページネーション
    2.3. Rate limits
  3. ソースコード
  4. 感想

1. 概要と前提

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

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

注意:そもそも、Slackには、会話履歴などのデータをexportする機能が備わっています。
参考:ワークスペースのデータをエクスポートする

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

  • Slackのexportを使えない
  • Slackのexportを使いたくない
  • Slack APIの実装力をつけたい
  • TypeScriptでconversations.repliescursorを指定してページネーションする具体的な実装を知りたい
    などなど。

2. 実装のポイント

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

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

この中で、特にconversations.repliesの扱いについて苦労しました。

そこで、今回は、conversations.repliesのドキュメントに補足する形で実装する上で躓いた3つのポイントを解説していきます。

2.1. conversations.repliesのスコープ

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

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

2.2. curosrベースのページネーション

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

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

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

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

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

2.3. Rate limits

次に、Slackの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という、リクエストの制限数が存在します。

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

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

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

3. ソースコード

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

ご査収ください。

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の仕様、ライブラリの扱い、などなど、初めてのことだらけでなかなか右往左往しています。しかし、「なんかよくわからんけど実装できました」だと、会社にとっても自分にとってもよくないため、ドキュメントをいっぱい調べながら開発しています。その中で調べたドキュメントのリンクをこの記事にも色々貼らせていただきました。

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

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?