Slack で Private channel や DM を含む発言とファイルをエクスポートしてみる


## 追記（2024 年 7 月）

:::note warn
先月 Slack から、2024年 8 月 26 日以降、無料ワークスペースでは 1 年以上経過したメッセージやファイルを順次削除する方針が発表されました。
これまでは 90 日間を過ぎた発言やファイルはアクセスできなくなるだけで Slack のサーバ上には保存されていましたが、以降は 1 年で削除されると思われます。
:::

https://slack.com/intl/ja-jp/help/articles/29414264463635

よって、各自でバックアップを取得する必要があります。
下記の通り、公式のエクスポート機能に加え、（今のところ）スクリプトも正常に動作するように見えますので、適宜ご利用ください。

なお、2022 年 ９ 月 1 日の仕様変更以降、**API 経由でも 90 日の制限が適用されている** ように見受けられます。
そのため、スクリプト経由で全データを取得したい場合は、1 ヶ月のみ有料プランに加入する，フリートライアル期間中に実施するなどで対応ください。

## 背景

2022 年 9 月 1 日から Slack フリープランにおけるメッセージ・ファイルの閲覧制限の仕様が変更となり、**「メッセージ数 10,000 件、ストレージ容量 5 GB」から「過去 90 日間」になる旨** が[アナウンス](https://slack.com/intl/ja-jp/blog/news/pricing-and-plan-updates)されています。
そのため、特に少人数で利用しているワークスペースでは、これまで閲覧できていたデータにアクセスできなくなる恐れがあります。

API 経由でアクセスした際の仕様は明に述べられていないようですが、夏休みの宿題が如く、Private channel や Direct Message (DM) を含む各会話の発言と、そこで共有されたファイルをエクスポートするスクリプトを書いてみました。

なお、Public channel の発言のみで良ければ、[Slack 公式のエクスポート機能](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 App の準備

[Slack API](https://api.slack.com/) を利用するので、事前に Slack App を作ってトークンを生成する必要があります。
Slack App の作り方は既にたくさんの記事があり、詳細を割愛しますが、大まかには以下の手順で作成できます。

1. Slack API ページの右上にある "Your App" からアプリを新規作成する
   - "From scratch" を選択して、データを取得したいワークスペースを選択する
2. メニューから "OAuth & Permissions" に遷移し、「Scopes」の項でスコープ（権限）を追加する
   - Bot / User Token いずれかのみを利用する場合は、片方だけで良い
   - 良く分からなければ、とりあえず両方追加して良い（後で少し補足）
   ```:追加するスコープの一覧
   channels:history, channels:read
   files:read
   groups:history, groups:read
   im:history, im:read
   mpim:history, mpim:read
   users:read
   ```
3. ｢OAuth Tokens for Your Workspace」の項で、アプリをワークスペースにインストールする
   - スコープを追加した際は、都度インストールが必要なので注意
4. インストール後、トークンが生成されれば OK
   ![slack-app-tokens.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21122/a85bf082-84d8-e813-e301-061baa03dbe8.jpeg)


### スクリプトの実行

基本的には、以下のスクリプトを clone して実行するだけです。
（Git 操作に馴染みが無ければ、「Code」から Zip ファイルをダウンロードして解答する手順でも大丈夫です。）

https://github.com/SorAmber/slack-data-export

1. 生成したトークンを下記に反映する。
   ```python:const.py
   USER_TOKEN = "xoxp-xxxxxx"  # Your User Token
   BOT_TOKEN = "xoxb-xxxxxx"  # Your Bot Token
   ```
2. 必要な外部モジュールをインストールする。
   ```
   $ pip install requests
   $ pip install slack-sdk
   ```
3. 実行する。
   ```
   $ python main.py
   ```

処理が完了すると `./export/` 配下に zip ファイルが生成されます。
この中に、各会話の発言とファイルが含まれています。

### どちらのトークンを使うべきか

今回の目的を考えると User Token が妥当かなと思いつつ、少しだけ補足します。

User Token は Slack App 作成者のアカウントから見える情報に、Bot Token は作成された Slack App から見える情報に、それぞれアクセスできます。
そのため、自分が特定のユーザとやり取りした DM の履歴等を取得したい場合、必然的に User Token を選択することになると思います。

試しに、複数人でのグループ DM に途中から App を加えてみたところ、別の会話が新規に開始されてしまいました。
したがって、Bot Token では、App を加える前の会話履歴は取得できないと思われます。

ちなみに、具体的にどちらのトークンを利用するかは、以下で変更できます。

```python:const.py
    USE_USER_TOKEN = True
```

### 外部ビュアーとの連携

最初に Slack 公式のエクスポート機能があると書きましたが、このデータを取り込んで Slack の UI っぽく表示するサードパーティ製のビュアーが、いくつか作成されています。
例えば、[slack-export-viewer](https://github.com/hfaran/slack-export-viewer) が挙げられます。
今回、メッセージの出力形式を公式のエクスポート機能に似せて、slack-export-viewer で表示させることができました。
![slack-export-viewer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21122/d29db2ee-b15b-9f85-2c11-aeca623ebbfa.png)
なお、Private channel や DM も一律 Public channel の欄に入ってしまいましたが、そもそも公式機能では Public channel しか取得できないので、その兼ね合いだと思われます。
そのため、他と区別できるように、DM には頭に `@` をつけてエクスポートしています。

## 実装時のあれこれ

ここまでで、使い方的な話はおしまいになります。
以降は、実装中に Slack 周りで気付いた・手間取った点をいくつか記載したいと思いますので、興味が無い方は読み飛ばしていただいて大丈夫です。
（このセクションを書きたくて記事にしたのは内緒。）

### 基本的なこと

前提として API の一覧は [こちら](https://api.slack.com/methods)。
各 API の説明ページにはサンプルコードやテスターも揃っていて、ドキュメントが若干粗いかな…… ? とは感じるものの、全体的には分かりやすいです。

### 会話一覧の取得

`conversations.list` で、渡したトークンにてアクセス可能な会話の一覧を取得できます。
この時、`types` を指定することで各種類の会話（Public channel, Private channel, DM, グループ DM）のチャンネル情報を取得できますが、DM の場合のみ `"name"` が存在しません。

```jsonc:conversations.list
{
    "ok": true,
    "channels": [
        // DM 以外の場合
        {
            "id": "CHxxxxxx1",
            "name": "general",  // "name" がある
            "is_channel": true,
            "is_group": false,
            "is_im": false,
            "is_mpim": false,
            "is_private": false
        },
        // DM の場合
        {
            "id": "DMxxxxxx1",
            "is_im": true,  // "name" を始め "is_channel" 等も無い……
            "user": "USERxxxx1"
        }
    ]
}
```

よって、そのまま書き出すと誰との DM なのかが分からないため、別途ユーザの一覧を取得する `users.list` の応答を用いて、ユーザ ID を経由してユーザ名を取得する必要があります。

```jsonc:users.list
{
    "ok": true,
    "members": [
        {
            "id": "USERxxxx1",  // "user" と突合して "real_name" を取得
            "name": "soramber",
            "real_name": "soramber"
        }
    ]
}
```

また、前述した slack-export-viewer は Public channel での利用が前提のため、全てのチャンネル情報に `"name"` が存在しないとエラー終了します。
これを回避する観点でも、`conversations.list`の応答結果に `"name"` を加えて保存する必要がありました。

（正直、DM は名前が自明なので `conversations.list` でさくっと返してほしい気も……。）

### メッセージの取得

`conversations.history` で、特定の channel のメッセージを取得できますが、thread 内でやり取りされたメッセージは対象外となります。
thread 内のメッセージを取得したい時は、各 thread ごとに `conversations.replies` を呼ぶ必要がありますが、この応答には thread の親メッセージも含まれて返却されます。
したがって、2 つの応答結果を素直に結合すると各 thread の親メッセージが二重に保存されるため、注意が必要です。

```jsonc:conversations.history
{
    "ok": true,
    "messages": [
        // thead の親メッセージ
        {
            "type": "message",
            "text": "parent message 1",
            "ts": "1660000000.000000",  // このメッセージのタイムスタンプ
            "thread_ts": "1660000000.000000",  // 親の場合は "ts" と同じ
            "reply_count": 1,
            "reply_users_count": 1,
            "latest_reply": "1660001111.111111",
            "reply_users": [
                "USERxxxx1"
            ]
        }
    ]
}
```

```jsonc:conversations.replies
{
    "ok": true,
    "messages": [
        // thread の親メッセージ（重複）
        {
            "type": "message",
            "text": "parent message 1",
            "ts": "1660000000.000000",
            "thread_ts": "1660000000.000000",
            "reply_count": 1,
            "reply_users_count": 1,
            "latest_reply": "1660001111.111111",
            "reply_users": [
                "USERxxxx1"
            ],
        },
        // thread 内のメッセージ
        {
            "type": "message",
            "text": "thread message 1-1",
            "ts": "1660001111.111111",  // このメッセージのタイムスタンプ
            "thread_ts": "1660000000.000000",  // 親メッセージのタイムスタンプ
        },
    ]
}
```

なお、thread 内でのメッセージ投稿時に「チャンネルにも投稿する」のチェックを入れた状態であっても `conversations.history` には載らない（あくまでも thread 内のメッセージは `conversations.replies` でのみ返る）ので、その観点での処理は不要でした。

### ファイルの存在確認

`files.list` もありますが、今回は前述した `conversations.history` と `conversations.replies` で取得したメッセージからファイルの情報を抽出してダウンロードする方式を採りました。
各メッセージの中に `"files"` があれば、ファイル添付されたメッセージであることが分かります。

```json:通常時
"files": [
    {
        "id": "FILExxxxxA",
        "name": "sample.jpg",
        "mimetype": "image/jpeg",
        "filetype": "jpg",
        "mode": "hosted",
        "url_private": "https://files.slack.com/files-pri/xxxxxx/sample.jpg"
    }
]
```

この `"files"` の中にある `"url_private"` にアクセスすればファイルを取得できるのですが、どうやらファイルが削除済みの時 ? は `url_private` のキー自体が存在しない応答になるようです。
ドキュメント上の記載は見当たりませんでしたが、[同じ話題が議論](https://github.com/zulip/zulip/issues/11619)されていました。
この場合 `"files"` の要素は `"id"` と `"mode"` のみになるため、まず `"mode"` で判定する必要があります。

```jsonc:削除済み ? の時
"files": [{
    "id":"FILExxxxxB"
    "mode":"tombstone"  // 削除されたっぽい
}]
```

### ファイルのダウンロード

ファイルをダウンロードする API が見当たらず、Python の Requests モジュールを利用しました。
トークンは、リクエストヘッダーに積む必要があります。

```python
response = requests.get(
    url_private,
    headers={"Authorization": "Bearer " + token},
    timeout=(connect_timeout, read_timeout))

# でも、正常にダウンロードできない……
print(response.status_code)  # -> 200
```

後から考えれば当たり前なのですが、この時、利用するトークンのスコープに `files:read` が付与されていないと正常にダウンロードできません。
Requests の処理自体は問題なく行われるため、原因の特定に少し手間取りました。

その後、出力されたファイルの中身を確認するとリダイレクトしていることが分かりました。
（ブラウザにて、未ログイン状態でファイルの URL を開こうとすると、自動でログイン画面に遷移する流れと同じです。）

Requests はデフォルトでリダイレクトに対応するので、途中のステータスコードを調べる `Response.history` やリダイレクトを無効化する `allow_redirects=False` オプションにより、`files:read` の有無を検知できました。

```python
if len(response.history) != 0:
    print(response.history[0])  # -> <Response [302]>
```

```python
response = requests.get(
    url_private,
    headers={"Authorization": "Bearer " + token},
    timeout=(connect_timeout, read_timeout),
    allow_redirects=False)

print(response.status_code)  # -> 302
```

## おわりに

初めて Qiita に投稿してみたので、拙文ご容赦いただければ幸いです。
また、一気に書いてしまったので、間違っている箇所等あればコメントでご指摘いただけると嬉しいです。

Slack は日常的に利用しており、何かしら還元したい気持ちはありつつも、ワークスペースごとに参加人数分の費用が発生するのは、なかなか厳しいなーと感じています。
個人向けに、所属ワークスペース数に依存せずに利用できるパスポート的なプランが出てくることを願いつつ。