Slack Python SDK でチャンネルにファイルをアップロードしよう

## slack-sdk を使おう

「[Python で Slack API や Webhook を扱うなら公式 SDK（slack-sdk/slack-bolt）を使おう](https://qiita.com/seratch/items/8f93fd0bf815b0b1d557)」シリーズの記事です。公式 SDK についての概要、他にも記事がリンクされていますので、[こちらのエントリーページ](https://qiita.com/seratch/items/8f93fd0bf815b0b1d557)にもアクセスしてみてください。

この記事では [`slack-sdk` PyPI パッケージ](https://pypi.org/project/slack-sdk/)を使って Slack にファイルをアップロードするコーディングについて解説していきます。

## files.upload API を使うだけ

と言っても、実は `files.upload` という API の使い方を理解するだけですので、とても簡単です。英語のみとなりますが、API ドキュメントは以下の URL です。

https://api.slack.com/methods/files.upload

この記事では Python SDK でどのようにこの API を使うかをコード例とともに紹介していきます。

## Slack アプリ設定をつくってインストールする

### App Manifest を使ってアプリ設定をつくる

[こちらの URL](https://api.slack.com/apps?new_app=1&manifest_yaml=_metadata%3A%0A++major_version%3A+1%0A++minor_version%3A+1%0Adisplay_information%3A%0A++name%3A+File+Writer+App%0Afeatures%3A%0A++bot_user%3A%0A++++display_name%3A+File+Writer+Bot%0Aoauth_config%3A%0A++scopes%3A%0A++++bot%3A%0A++++++-+chat%3Awrite%0A++++++-+files%3Aread%0A++++++-+files%3Awrite%0A) からアプリを新しく作ってください。

この URL には以下の App Manifest が埋め込まれており、ワークスペースを選ぶだけでアプリ設定を完了することができます。

```yaml
_metadata:
  major_version: 1
  minor_version: 1
display_information:
  name: File Writer App
features:
  bot_user:
    display_name: File Writer Bot
oauth_config:
  scopes:
    bot:
      # チャンネルにメッセージを投稿するのに必要
      - chat:write
      # ファイルのアップロードだけなら不要、ここでは files.info / files.list を使うため
      - files:read
      # ファイルをアップロードするために必要
      - files:write
```

### Slack ワークスペースにインストールする

アプリ設定ができたら、そのまま画面にある「Install to Workspace」ボタンから Slack ワークスペースにインストールします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/dcbc352c-f03a-4e9a-9ad5-bcf0faa40f3b.png" width=500 />
いつものアプリインストール確認画面で「Allow」をクリックします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/27528bfa-26ee-040d-13bd-01ca53b5fe52.png" width=500 />
インストールが終わったら、左のメニューから **Settings** > **Install App** に移動して、アクセストークンを取得してください。ご覧の通り、ここはこの記事時点で少しわかりにくくなっていますが、この辺の導線は、今後のアプリ設定画面のアップデートで改善されるかと思います。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/efd54d47-b3ac-cc75-94d3-cb504156e5ee.png" width=500 />

このトークンの値を環境変数 `SLACK_BOT_TOKEN` に設定しましょう。これで Slack アプリ設定画面での準備は完了です。

```bash
export SLACK_BOT_TOKEN=xoxb-11111111111-2222222222-dssdfswsddjfh34sfdsfs
```

## Python コードでファイルをアップロードする

### プロジェクトの新規作成

まず使用する Python のバージョンが 3.6 以上であるかを確認してください。

最近では、システム標準の `python3` や `pip3` などのコマンドも 3.6 以上のバージョンだとは思いますが、常に最新のバージョン（この記事投稿時点で 3.10 です）を使用するために [pyenv](https://github.com/pyenv/pyenv) などのツールを使って Python のランタイムを管理することをお勧めします。

その 3.6 以上の Python で、以下のような依存ライブラリを解決したまっさらな環境を作ります。

```bash
echo 'slack-sdk>=3.12,<4' > requirements.txt
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
```

この状態で REPL を使ってもよいですが、以下ではスクリプトとして実行できるコード例を示していきます。

### ただファイルをアップロードしてみる

まずはじめに以下のどのコードでも共通の部分です。以下では繰り返しませんが、スクリプトの最初には必ず含めるようにしてください。

```python
import logging, os

# デバッグレベルのログを出力します
# 実際に使うスクリプト・アプリではデフォルトの INFO レベルにして
# 必要なログは自分で追加のログを出力するようにしましょう
logging.basicConfig(level=logging.DEBUG)

# Web API クライアントを初期化します
from slack_sdk import WebClient
# export SLACK_BOT_TOKEN= のように環境変数があらかじめ設定されている前提です
# トークンを直接コードに埋め込むことは避けましょう
client = WebClient(os.environ["SLACK_BOT_TOKEN"])
```

それではまずはトークンが正しいかを確認します。

```python
# トークンが正しいか確認します
auth_test = client.auth_test()
bot_user_id = auth_test["user_id"]
print(f"App's bot user: {bot_user_id}")
```

以下のような出力になるでしょう。

```python
>>> auth_test = client.auth_test()
DEBUG:slack_sdk.web.base_client:Sending a request - url: https://www.slack.com/api/auth.test, query_params: {}, body_params: {}, files: {}, json_body: None, headers: {}
DEBUG:slack_sdk.web.base_client:Received the following response - status: 200, headers: {}, body: {"ok":true,"url":"https:\/\/example.slack.com\/","team":"Acme Corp","user":"file_writer_bot","team_id":"T1234567890","user_id":"U02PY3HA48G","bot_id":"B02P8CPE143","is_enterprise_install":false}

>>> bot_user_id = auth_test["user_id"]
>>> print(f"App's bot user: {bot_user_id}")
App's bot user: U02PY3HA48G
```

問題ないようです。bot user のユーザー ID を出力していますが、これからアップロードするファイルはすべてこのユーザーがアップロードしたもの（この bot user の持ち物）として扱われます。

まだ何もしていないので、実行しても意味がないですが、このユーザーがアップロードしたファイル一覧を確認するには以下のようなコードを実行します。後ほどファイルをアップロードしたら、再度実行してみましょう。

```python
>>> files = client.files_list(user=bot_user_id)
DEBUG:slack_sdk.web.base_client:Sending a request - url: https://www.slack.com/api/files.list, query_params: {}, body_params: {'user': 'U02PY3HA48G'}, files: {}, json_body: None, headers: {}
DEBUG:slack_sdk.web.base_client:Received the following response - status: 200, headers: {}, body: {"ok":true,"files":[],"paging":{"count":100,"total":0,"page":1,"pages":0}}
```


それでは、いよいよファイルをアップロードしてみます。まずは `content` パラメーターを使ってテキストデータをファイルとしてアップロードします。

```python
new_file = client.files_upload(
    title="My Test Text File",
    filename="test.txt",
    content="Hi there! This is a text file!",
)
```

REPL で実行してみると、どうやら成功したようです。

```python
>>> new_file = client.files_upload(
...     title="My Test Text File",
...     filename="test.txt",
...     content="Hi there! This is a text file!",
... )
DEBUG:slack_sdk.web.base_client:Sending a request - url: https://www.slack.com/api/files.upload, query_params: {}, body_params: {'filename': 'test.txt', 'title': 'My Test Text File', 'content': 'Hi there! This is a text file!'}, files: {}, json_body: None, headers: {}
DEBUG:slack_sdk.web.base_client:Received the following response - status: 200, headers: {}, body: {"ok":true,"file":{"id":"F02P5J88137","created":1638414790,"timestamp":1638414790,"name":"test.txt","title":"My Test Text File","mimetype":"text\/plain","filetype":"text","pretty_type":"Plain Text","user":"U02PY3HA48G","editable":true,"size":30,"mode":"snippet","is_external":false,"external_type":"","is_public":false,"public_url_shared":false,"display_as_bot":false,"username":"","url_private":"https:\/\/files.slack.com\/files-pri\/T03E94MJU-F02P5J88137\/test.txt","url_private_download":"https:\/\/files.slack.com\/files-pri\/T03E94MJU-F02P5J88137\/download\/test.txt","permalink":"https:\/\/seratch.slack.com\/files\/U02PY3HA48G\/F02P5J88137\/test.txt","permalink_public":"https:\/\/slack-files.com\/T03E94MJU-F02P5J88137-e3fda671e9","edit_link":"https:\/\/seratch.slack.com\/files\/U02PY3HA48G\/F02P5J88137\/test.txt\/edit","preview":"Hi there! This is a text file!","preview_highlight":"<div class=\"CodeMirror cm-s-default CodeMirrorServer\" oncopy=\"if(event.clipboardData){event.clipboardData.setData('text\/plain',window.getSelection().toString().replace(\/\\u200b\/g,''));event.preventDefault();event.stopPropagation();}\">\n<div class=\"CodeMirror-code\">\n<div><pre>Hi there! This is a text file!<\/pre><\/div>\n<\/div>\n<\/div>\n","lines":1,"lines_more":0,"preview_is_truncated":false,"comments_count":0,"is_starred":false,"shares":{},"channels":[],"groups":[],"ims":[],"has_rich_preview":false}}
```

`files.list` API で確認してみても、ファイルは確かにアップロードされています（`files.list` のデータ反映は少しラグがある場合もあります）。

```python
>>> files = client.files_list(user=bot_user_id)
DEBUG:slack_sdk.web.base_client:Sending a request - url: https://www.slack.com/api/files.list, query_params: {}, body_params: {'user': 'U02PY3HA48G'}, files: {}, json_body: None, headers: {}
DEBUG:slack_sdk.web.base_client:Received the following response - status: 200, headers: {}, body: {"ok":true,"files":[{"id":"F02P5J88137","created":1638414790,"timestamp":1638414790,"name":"test.txt","title":"My Test Text File","mimetype":"text\/plain","filetype":"text","pretty_type":"Plain Text","user":"U02PY3HA48G","editable":true,"size":30,"mode":"snippet","is_external":false,"external_type":"","is_public":false,"public_url_shared":false,"display_as_bot":false,"username":"","url_private":"https:\/\/files.slack.com\/files-pri\/T03E94MJU-F02P5J88137\/test.txt","url_private_download":"https:\/\/files.slack.com\/files-pri\/T03E94MJU-F02P5J88137\/download\/test.txt","permalink":"https:\/\/seratch.slack.com\/files\/U02PY3HA48G\/F02P5J88137\/test.txt","permalink_public":"https:\/\/slack-files.com\/T03E94MJU-F02P5J88137-e3fda671e9","edit_link":"https:\/\/seratch.slack.com\/files\/U02PY3HA48G\/F02P5J88137\/test.txt\/edit","preview":"Hi there! This is a text file!","preview_highlight":"<div class=\"CodeMirror cm-s-default CodeMirrorServer\" oncopy=\"if(event.clipboardData){event.clipboardData.setData('text\/plain',window.getSelection().toString().replace(\/\\u200b\/g,''));event.preventDefault();event.stopPropagation();}\">\n<div class=\"CodeMirror-code\">\n<div><pre>Hi there! This is a text file!<\/pre><\/div>\n<\/div>\n<\/div>\n","lines":1,"lines_more":0,"preview_is_truncated":false,"channels":[],"groups":[],"ims":[],"comments_count":0}],"paging":{"count":100,"total":1,"page":1,"pages":1}}
```

しかし、Slack ワークスペースを見ても何も起きていませんね？これはどういうことでしょうか？

### ファイルをチャンネルで共有する

この時点では、ファイルは確かに Slack にアップロードされたのですが、まだこのアプリの bot user 以外には見えない状態になっています。

API を使って bot user にこのファイルを他のユーザーと共有させましょう。そのために `chat.postMessage` API を使ってメッセージを投稿します。

`#random` チャンネルを使うことにしましょう。以下のように **File Writer Bot** をメンションして `#random` チャンネルに invite してください。 

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/cfa570f7-6031-060b-878a-83068ecb460d.gif" width=500 />

そして、以下のようにファイルの URL を取得したら、それを含めるメッセージを投稿してください。

```python
file_url = new_file.get("file").get("permalink")
new_message = client.chat_postMessage(
    channel="#random",
    text=f"Here is the file: {file_url}",
)
```

するとこのようにファイルが表示されるようになりました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/db507427-b213-93d6-36d7-3ab64f3bd279.png" width=500 />

### ファイルアップロード時にチャンネルを指定する

しかし、毎回このようなことをするのは手間ですね。これを一括でやるなら、以下のように `channels` パラメーターを設定します。ファイルアップロードをアプリからやるのは、このやり方が一般的です。

なお `channels` の値は、以下のコード例のように一つの str を渡しても str の配列で複数のチャンネル ID を渡してもどちらでも問題ありません。また、ここでは簡便化のために `#random` を指定していますが、一般的にはチャンネル ID を指定することを推奨しています（チャンネル名はいつでも変更される可能性があるため）。

```python
upload_and_then_share_file = client.files_upload(
    channels="#random",  # 複数のチャンネルを指定することも可能
    title="Test text data",
    filename="test.txt",
    content="Hi there! This is a text file!",
    initial_comment="Here is the file:",
)
```

これを実行すると、URL をリンクを含めないこと以外は全く同じようにファイルがチャンネルで共有されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/d6694f02-e400-20d8-4880-bc5f6ffb93af.png" width=500 />

### ローカルのファイルをアップロードする

それでは、ローカルにあるファイルをアップロードしてみましょう。手元に適当な画像ファイルなどがあればそれを使っても構いませんが、ここではコピペして実行できるように引き続きテキストファイルを使って説明します。ターミナルから以下を実行して `test.txt` というファイルを作ってください。

```bash
echo 'Hi there! This is a text file!' > test.txt
```

そして、そのディレクトリで以下の Python コードを実行します。`file` パラメーターでファイルのパスを指定します。

```python
upload_text_file = client.files_upload(
    channels="#random",
    title="Test text data",
    file="./test.txt",
    initial_comment="Here is the file:",
)
```

そうすると全く同じようにファイルがアップロードされていることがわかります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/c8a368a4-c2a3-d385-3068-8c3762cfc108.png" width=500 />

`files.upload` API では、ここで紹介したもの以外にもいくつかパラメーターがサポートされていますので、色々と試してみてください。

### （おまけ）ファイルを削除する

おまけでファイルを削除する方法などを紹介してみようと思います。

今 3 つのファイルを上のコードでアップロードしています。

```python
file_ids = []
# Python SDK は for での自動ページネイションに対応しています
for page in client.files_list(user=bot_user_id):
    for file in page.get("files", []):
        file_ids.append(file["id"])

print(file_ids)
```

上記のコードを実行すると 3 つのファイル ID を確認できます。

```python
>>> file_ids
['F02P5J88137', 'F02P8H5BN9G', 'F02P5K7T8SZ']
```

これらを全て削除してみましょう。

```python
for page in client.files_list(user=bot_user_id):
    for file in page.get("files", []):
        client.files_delete(file=file["id"])
```

削除すると `files` の配列はすぐに空になるはずです。`paging` の方の数字は少しラグがある場合もあります。

Slack ワークスペースの UI 上でも以下のようにファイルが削除された旨の表示となりました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/3b64ca5c-4659-26f5-85e0-941ab99d665f.png" width=500 />

## まとめ

この記事では Slack API を使ってファイルをアップロードし、チャンネルで共有する手順について、公式 Python SDK のコード例とともに紹介しました。Python 以外でもやることは基本的に同じです。まず Python でサッと試してから実際の言語のコードに落としていくのもよいかもしれません。

それでは :wave: