目次
- 概要と前提
- 実装のポイント
2.1.conversations.replies
のスコープ
2.2.curosr
ベースのページネーション
2.3. Rate limits - ソースコード
- 感想
1. 概要と前提
Slackのメッセージの会話履歴の取得をTypeScriptで実装していきます。
本記事では、私が実装する上で躓いたところを中心に解説していきます。そのため、全ての手順を詳細に解説するわけではありません。上記の目次を参考に、必要な箇所だけつまみ食いしていただければ幸いです。
注意:そもそも、Slackには、会話履歴などのデータをexportする機能が備わっています。
参考:ワークスペースのデータをエクスポートする
そのため、以下の条件に当てはまらない方は、遠回りをすることになってしまうかもしれないのでご注意ください。
- Slackのexportを使えない
- Slackのexportを使いたくない
- Slack APIの実装力をつけたい
- TypeScriptでconversations.repliesの
cursor
を指定してページネーションする具体的な実装を知りたい
などなど。
2. 実装のポイント
実装の基本的な流れとしては、以下の4ステップです。
- 使用する変数の準備(チャンネルID、期間、など)
- conversations.historyでメッセージとそのメタデータなどを取得
- conversations.repliesでメッセージのリプライを取得
- 好きな形に加工(今回は、会話履歴とリンクのオブジェクトにします)
(ソースコードは本記事の最後に記載します)
この中で、特にconversations.repliesの扱いについて苦労しました。
そこで、今回は、conversations.repliesのドキュメントに補足する形で実装する上で躓いた3つのポイントを解説していきます。
2.1. conversations.replies
のスコープ
まず、conversations.replies
を使用するためのスコープとして、以下のいずれか一つ以上のスコープが必要です。
-
channels:history
:パブリックチャンネルの履歴取得に必要 -
groups:history
:プライベートチャンネルの履歴取得に必要 -
im:history
:1対1のDMの履歴取得に必要 -
mpim:history
:複数人のDMの履歴取得に必要
今回は、パブリックチャンネルの会話履歴取得を行いたいため、channels:history
のみ設定します。(ユーザIDをユーザ名に置き換えたいなど、他に行いたい実装があれば、そのためのスコープを設定してください)
2.2. curosr
ベースのページネーション
Slack APIでは、cursor
ベースのページネーションによって、大量のデータを効率的に取得することができます。具体的には、大量のデータをいくつかのページに分割し、1ページごとに通信を行います。
cursor
の詳細な挙動に関しては、ドキュメントを参照ください。今回は、ページネーションを実装する上で押さえておくべきポイントをステップバイステップで解説してみます。
- ページネーションを行いたいメソッド(今回は
conversations.history
とconversations.replies
)に、以下2つの引数を与える-
limit
:1ページ当たりのデータの数(100~200の間の数値が推奨されている) -
cursor
:次のページの有無
-
- レスポンスのトップレベルに存在する
has_more
を確認する -
hes_more
がtrue
の時、同じくレスポンスのトップレベルに存在するresponse_metadata
にnext_cursor
というハッシュ値が存在する。このnext_cursor
を、2ページ目以降のリクエストからcursor
にセットする。 -
hes_more
がfalse
になるまでリクエストをループさせる
以上の手順でページネーションを実装することができます。
注意点として、ドキュメントでは、next_cursor
は、emptyかnullかnon-existentと記述されており、ドキュメントの例では、基本的に空文字列(""
)として扱われています。そのため、私は最初、cursor
をstring
型として扱っていました。しかし、私の環境では、response_metadata
にnext_cursor
が存在しなかったため、cursor
をstring | 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の仕様、ライブラリの扱い、などなど、初めてのことだらけでなかなか右往左往しています。しかし、「なんかよくわからんけど実装できました」だと、会社にとっても自分にとってもよくないため、ドキュメントをいっぱい調べながら開発しています。その中で調べたドキュメントのリンクをこの記事にも色々貼らせていただきました。
私が調べた知識が、同じ新人エンジニアの方々の助けになれば幸いです。