勉強がてら Slack Web API を用いて指定チャンネルの全メッセージをエクスポートしてみた


Slack 上のメッセージをローカルに保存したかったので、勉強がてら Slack Web API を使ってエクスポートしてみました。

書いたもの

GitHub にアップしました 。Python2 で requests ライブラリを使って Slack Web API を叩いてます。

前提

  • 私はひとりで Slack を使っています
    • 複数人で使っている人は(権限が無くて)試せない可能性があります
  • Python の requests で REST API を使ったことがあります
    • REST API や request ライブラリ自体の解説はしません

Slack Web API について

Slack Web API について、どんな風に使うのかとかお作法や制約はあるかなど、個人的に勉強したことやハマったことなどをまとめます。

認証について

事前にアクセストークンを取得しておき、これをリクエスト時に併せて送信することで行います。

私は Legacy Token を使いました。私はひとり Slack をしており Legacy で十分だからです。

Legacy Token について書いておきます。Legacy Token は言うなれば root ユーザー的なやつで、これ一つで何でもできます。元々 Slack Web API のトークンは Legacy Token だけでしたが、それではさすがに危ないだろうということで 別のトークン体系 が作られ、元々あったものは Legacy となった……という歴史があったと思います(たしか)。

※別のトークン体系: 詳しくは見てませんが「アプリケーション」という単位を登録した後、それに対して必要な権限を追加していく、というイメージみたいです。「READ 限定」「チャンネルAへのPOSTのみ可能」みたいに細かく使い分けるイメージでしょうか?

使用したメソッド

Slack Web API のメソッド一覧 を見ながら、使えそうなやつを試してみた結果、以下を使いました。

必須なのは channels.list と channels.histrory の二つです。search.messages は(後述しますが)精神衛生を保つためのおまけです。

指定チャンネルの全メッセージを取るまでの流れ

指定チャンネルの全メッセージを取るまでの流れは以下になります。

  • 取得したいチャンネルのチャンネルIDを手に入れる
  • チャンネルIDを指定して channels.history メソッドを実行する

チャンネルID は channels.list で取れます。

メッセージは channels.history で取れます(こやつがチャンネルIDしか受け付けないため最初にIDを取っておく必要があります)。ただしメッセージは一度のリクエストで最大1000件しか取れないので、メッセージ数が1000件を越える場合は繰り返しリクエストを投げてやる必要があります(ページネーション)。

ページネーションについて

ページネーションの仕様はドキュメントに書いてあります が、メソッドによって仕様が異なってややこしいです。

channels.history はというと Timeline methods という仕様になってます。これは latest と oldest の二つのパラメータにて「ここからここまでのメッセージを取るよ」を指定するやり方です。指定するのはタイムスタンプです。 1486815683.000003 ← こんなのですね。

そのタイムスタンプとやらをどうやって決めるのか、という話ですが、channels.history で取得したメッセージデータにはタイムスタンプも含まれているため、それを取り出して使います。

Rate Limit(通信制限)について

Web API は好き放題叩けるわけではなく「一時間にn回までにしてね」「これ越えたら制限かけますんで」といった仕組み(Rate Limit)があります。

Slack については Rate Limits | Slack に書いてありますが、「一時間にn回以内」といった明確な基準は無く、

  • (メッセージ投稿については)1秒1回以内、短時間ならバーストも可能、くらいの意識で使うべき
  • 使いすぎた場合は HTTP 429 Too Many Requests レスポンスが返される
    • レスポンスヘッダの Retry-After 値に「あと何秒で制限が解除されるか」が書いてある

といった具合です。

実装上の工夫

今回書いてみた slack_exporter スクリプト中で工夫したこと(Slack Web API が絡むもの)をまとめてみます。

取得件数を先に取得しておく

channels.history だけでは「全部で何件のメッセージが眠っているのか」がわかりません。いつ終わるかわからない取得処理を待ち続けるのは精神衛生上好ましくないです。……というわけで、全部で何件あるかを先に取得することにしました。

使ったのは search.messages メソッドです。クエリには in:(指定したチャンネルID) を指定してリクエストします。その後、返ってきたレスポンスから response['messages']['paging']['total'] を見れば総件数がわかります。

課題

slack_exporter でやり残した問題についてまとめておきます。

メッセージの「総件数」と「実際に取得した件数」が合わない

  • (Expect) search.messages で事前に取得したメッセージ総件数
  • (Actual) channels.history のページネーションで取得しきったメッセージの総数

なぜか知らないのですが両者の数が合わないんですよね。既に判明しているのは

  • search.messages ではシステムメッセージ(XXXXさんがjoinしました、的なやつ)をカウントしない
  • channels.history ではシステムメッセージも取る

くらいなのですが、他にも何か仕様だかバグだかが眠っているように思えます。

私のあるチャンネル(システムメッセージは数件のみ)でエクスポートしてみたところ、Expect が 3600 件で Actual が 1800 件なんてことがありました 原因を探して潰したいものです。

以下はページネーション部分のソース(一部抜粋)です。

        ...

        start_ts = args.start
        end_ts   = args.end
        out = ''
        trycount   = 1
        totalcount = (total/1000)
        if total%1000 != 0:
            totalcount += 1
        while True:
            start_ts_text = start_ts
            if start_ts==None:
                start_ts_text = 'Latest'
            print 'Getting {:}/{:} from {:} to next 1000.'.format(
                trycount, totalcount, start_ts_text)

            messages = self.get_messages(channel_id, 1000, start_ts, end_ts)

            messageinst_list = []
            for i,message in enumerate(messages):
                msg = Message(message)
                messageinst_list.append(msg)
                out += str(msg) + '\n'

            # 1リクエスト最大件数まで取れてない = もう全部取れた
            if len(messages)<1000:
                break

            # 次の取得開始位置となる timestamp を取る.
            # Slack API が順番を保証してくれてると信じて tail を見ちゃうよ.
            # ★この Qiita 書いてて気づいたんですが、
            # ★たぶんここは信じちゃいけない気がしますね……。
            # ★たしか GitHub API でもレスポンスデータの順番が保証されなくて
            # ★苦戦した覚えがあります……。
            start_ts = messageinst_list[-1].ts

            trycount += 1

        ...

「IFTTT の Twitter 連携で流されたメッセージ」の本文が空になっている

私の Slack には「指定キーワードで Twitter を検索した際の検索結果ツイート」が流れるチャンネルがあります。IFTTT を使って実現しています。

なぜかはわかりませんが、このようなチャンネルに対して channels.history でメッセージを取っても本文(text の値)が空になってしまいます。仕様なのでしょうか。

取れてないものはどうしようもないので、スクリプト中では以下のように無理矢理回避しました。

class Message:
    def __init__(self, d):
        ...

        self._text  = ''

        # channels.history で取得したメッセージの text(本文) には
        # 値が入っていないことがある.
        #   例: IFTTT Twitter 連携で流し込まれたメッセージ.
        # その場合はどうしようもないので空値として扱う.
        if ('text' in d) and (d['text']!=None):
            self._text  = d['text'].encode('utf-8')

        ...

おわりに

勉強がてら Slack Web API を触って、指定チャンネルの全メッセージをエクスポートする処理を書いてみました。

さすがエンジニアに揉まれてる人気サービスなだけあって、API はとても使いやすかったです。他のメソッドも触ってみたいところです(その前に課題を潰すのが先ですが )。