Slackのfiles.upload API廃止に伴う、移行対応について（Unity C#の場合を例に）

# はじめに
「[QualiArts Advent Calendar 2024](https://qiita.com/advent-calendar/2024/qualiarts)」の8日目の記事になります。

株式会社QualiArtsでUnityエンジニアをしております、石上です。
私が所属しているプロジェクトでは、開発のやり取りをSlackで行っています。
それに伴い、開発に便利なSlack Botがプロジェクトごとに用意され、活用されています。

一例として、

- ゲームの実機で、現在の画面のスクリーンショットを撮影し、端末のログと共にSlackにアップロードする

があり、スクリーンショットの画像をアップロードする部分で`files.upload`APIが使われていました。

しかし、Slackは、2025/03/11 にこのAPIを廃止することを発表しています。


![files_upload_deprecated.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3487124/2df9fea7-2ed0-c9bc-497c-57225a63a380.png)
https://api.slack.com/methods/files.upload より引用

そこで今回、私がその移行対応として、
新しいSlack APIを用いたアップロード処理の実装を行ったので、
他のプロジェクトなどでの移行対応の際の参考になればと思い、本記事を執筆することにしました。

今回は、UnityからC#スクリプトを用いてファイルアップロードを行う場合を例に説明します。

## APIを用いた、従来のSlackへのファイルアップロード方法
新しい方法を説明するに先立って、従来の方法についても軽く触れておきます。
APIを用いた、従来のSlackへのファイルアップロードは、以下の方法で行えていました。

1. [`files.upload` API](https://api.slack.com/methods/files.upload)宛に、送りたい画像のバイナリデータと、投稿したいSlackのチャンネルIDやアクセストークンなどを含めてリクエストを送る


以下は、pngファイルをSlackにアップロードするサンプルコードです。

<details><summary>従来のSlackへのファイルアップロードのサンプルコード</summary>

```csharp
//GetBinaryDataは、画像のバイナリデータを取得する独自メソッド
byte[] binaryData = image.GetBinaryData(); 
string fileName = "uploadFile.png";
string mimeType = "image/png";

var form = new WWWForm();
form.AddField("token", "ここにアクセストークンを書く");
form.AddField("channels", "ここにチャンネル名または、チャンネルIDを書く");
form.AddBinaryData("file", binaryData, fileName, mimeType);

using var request = UnityWebRequest.Post("https://slack.com/api/files.upload", form);
request.SendWebRequest();
```
</details>

# 新APIでのこれからのアップロード方法
これからは、以下の通り3種類のリクエストを送ってファイルのアップロードを行うことになります。

1. [`files.getUploadURLExternal`API](https://api.slack.com/methods/files.getUploadURLExternal)宛にリクエストを送って、ファイルのアップロード先のURLとファイルIDを受け取る
2. 先ほど受け取ったアップロード先のURL宛に、アップロードしたいファイルのバイナリデータを送信する
3. [`files.completeUploadExternal`API](https://api.slack.com/methods/files.completeUploadExternal)宛に、アップロードしたファイルに関連するファイルIDを送って、アップロードを確定させる

---


**（１）アップロード先のURL・ファイルIDを取得する処理**
アップロードしたいファイル名(`filename`)とファイルのサイズ（`length`）を指定して、画像のアップロード先のURL（`upload_url`）とファイルID（`file_id`）をリクエストで受け取ります。
`file_id`は後の`files.completeUploadExternal`APIを呼ぶ際に必要なので、覚えておきます。


<details><summary>アップロード先のURL・ファイルIDを取得する処理のサンプルコード</summary>

```csharp
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json;
using UnityEngine;
using UnityEngine.Networking;

public static class SlackUtility
{
    [Serializable]
    public class GetUploadUrlExternalResponse
    {
        [JsonProperty("upload_url")]
        public string uploadUrl;
    
        [JsonProperty("file_id")]
        public string fileId;
    }
    
    private static async UniTask<(string uploadUrl, string fileId)> GetUploadExternalUrlAndFileIdAsync(
        string fileName,
        byte[] binaryData,
        CancellationToken ct
    )
    {
        var form = new WWWForm();
        form.AddField("token", "ここにアクセストークンを書く");
        form.AddField("filename", fileName);
        form.AddField("length", binaryData.Length.ToString());
    
        using var request = UnityWebRequest.Post("https://slack.com/api/files.getUploadURLExternal", form);
        var response = await request.SendWebRequest().ToUniTask(cancellationToken: ct);
        var parsedResponse = JsonConvert.DeserializeObject<GetUploadUrlExternalResponse>(response.downloadHandler.text);
    
        if (parsedResponse == null)
        {
            return ("", "");
        }
    
        var uploadUrl = parsedResponse.uploadUrl;
        var fileId = parsedResponse.fileId;
    
        return (uploadUrl, fileId);
    }
}
```

</details>

**（２）ファイルをアップロードする処理**
（１）で取得した`upload_url`宛に、ファイルをアップロードします。
以下のサンプルコードでは、（1）を複数回行い、その後、複数ファイルを並行してアップロードしています。

<details><summary>ファイルをアップロードする処理のサンプルコード</summary>

```csharp
    [Serializable]
    public class CompleteUploadExternalRequestFile
    {
        [JsonProperty("id")]
        public string fileId;

        public string title;

        public CompleteUploadExternalRequestFile(string fileId, string title)
        {
            this.fileId = fileId;
            this.title = title;
        }
    }
    
    /// <summary>
    /// 複数ファイルをアップロードできる処理
    /// 後のfiles.completeUploadExternalのfilesパラメータに渡すために、
    /// アップロードしたファイルのfile_idとtitleをリストにして返す
    /// </summary>
    private static async UniTask<List<CompleteUploadExternalRequestFile>> UploadFilesAsync(IEnumerable<(string fileName, byte[] binaryData)> uploadFiles, CancellationToken ct)
    {
        var completeUploadRequestFiles = new List<CompleteUploadExternalRequestFile>();
        var uploadFileTasks = new List<UniTask>();

        foreach (var uploadFile in uploadFiles)
        {
            // (1)のサンプルコード内で定義した、アップロード先のURL・ファイルIDを取得する関数を呼ぶ
            var uploadUrlAndFileId = await GetUploadExternalUrlAndFileIdAsync(uploadFile.fileName, uploadFile.binaryData, ct);
            var uploadUrl = uploadUrlAndFileId.uploadUrl;
            var fileId = uploadUrlAndFileId.fileId;

            completeUploadRequestFiles.Add(new CompleteUploadExternalRequestFile(fileId, uploadFile.fileName));

            // ファイルアップロード処理をタスクに積む
            uploadFileTasks.Add(UploadFileAsync(uploadFile.binaryData, uploadUrl));
        }

        // アップロード処理を並列実行し、全てのアップロード処理が完了するまで待つ
        await uploadFileTasks;

        return completeUploadRequestFiles;
    }

    /// <summary>
    /// files.getUploadURLExternalのレスポンスとして返ってくるupload_urlに対してファイルをアップロードする
    /// </summary>
    /// <param name="binaryData">アップロードファイルのバイナリデータ</param>
    /// <param name="uploadUrl">アップロード先のURL</param>
    private static async UniTask UploadFileAsync(byte[] binaryData, string uploadUrl)
    {
        var form = new WWWForm();
        form.AddField("token", "ここにアクセストークンを書く");
        form.AddBinaryData("file", binaryData);
        using var uploadFileRequest = UnityWebRequest.Post(uploadUrl, uploadForm);
        await uploadFileRequest.SendWebRequest();
    }

```
</details>


**（３）ファイルのアップロードを確定させる処理**
（２）でアップロードしたファイルのファイルID（`file_id`）とタイトル（`title`）の組の配列のJSON文字列を`files`としてリクエストに含めて送り、ファイルのアップロードを確定させます。
このリクエストの際に渡した`channel_id`に合致するチャンネルにファイルが投稿されます。

<details><summary>ファイルのアップロードを確定させる処理のサンプルコード</summary>

```csharp
    /// <summary>
    /// Slackにファイルを投稿する
    /// </summary>
    /// <param name="channelId">投稿先のSlackチャンネルID</param>
    /// <param name="uploadFiles">アップロードするファイル名とバイナリデータ（複数ファイル指定可）</param>
    /// <param name="ct">CancellationToken</param>
    public static async UniTask PostUploadMultipleFilesAsync(
        string channelId,
        IEnumerable<(string fileName, byte[] binaryData)> uploadFiles,
        CancellationToken ct = default)
    {
        var completeUploadRequestFiles = await UploadFilesAsync(uploadFiles, ct);

        var uploadFileArrayString = JsonConvert.SerializeObject(completeUploadRequestFiles);

        var completeUploadExternalRequestForm = new WWWForm();
        completeUploadExternalRequestForm.AddField("token", "ここにアクセストークンを書く");
        completeUploadExternalRequestForm.AddField("channel_id", channelId);
        completeUploadExternalRequestForm.AddField("files", uploadFileArrayString);

        using var completeUploadExternalRequest = UnityWebRequest.Post("https://slack.com/api/files.completeUploadExternal", completeUploadExternalRequestForm);
        await completeUploadExternalRequest.SendWebRequest();
    }
```

</details>


# 作成したコード
前項のサンプルコードに加えて、
エラーハンドリングを追加し、整理して完成したコードが以下になります。
このコードは、
- Newtonsoft JSON
- UniTask

に依存しています。

<details open><summary>Slack APIでファイルアップロードを行うためサンプルコード（全文）</summary>

```csharp
using System;
using UnityEngine;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine.Networking;
using Newtonsoft.Json;

/// <summary>
/// Slack APIを叩くためのUtilityクラス
/// </summary>
public static class SlackUtility
{
    /// <summary>
    /// Slack APIを叩いた際の例外
    /// </summary>
    public class SlackException : Exception
    {
        public SlackException(string message) : base(message)
        {
        }
    }

    [Serializable]
    private class CompleteUploadExternalRequestFile
    {
        [JsonProperty("id")] public string fileId;

        public string title;

        public CompleteUploadExternalRequestFile(string fileId, string title)
        {
            this.fileId = fileId;
            this.title = title;
        }
    }

    public class SlackUploadFile
    {
        public string FileName { get; }
        public byte[] BinaryData { get; }

        public SlackUploadFile(string fileName, byte[] binaryData)
        {
            FileName = fileName;
            BinaryData = binaryData;
        }
    }

    private const string AuthToken = "ここにアプリの「Bot User OAuth Token」を書く";

    private const string SlackApiUrlPrefix = "https://slack.com/api/{0}";

    /// <summary>
    /// Slackにファイルを投稿する
    /// </summary>
    /// <param name="channelId">投稿先のSlackチャンネルID</param>
    /// <param name="uploadFiles">アップロードするファイル名とバイナリデータ（複数ファイル指定可）</param>
    /// <param name="ct">CancellationToken</param>
    public static async UniTask PostUploadMultipleFilesAsync(
        string channelId,
        IEnumerable<SlackUploadFile> uploadFiles,
        CancellationToken ct)
    {
        var completeUploadRequestFiles = await UploadFilesAsync(uploadFiles, ct);

        var uploadFileArrayString = JsonConvert.SerializeObject(completeUploadRequestFiles);

        var completeUploadExternalRequestForm = CreateBaseForm();
        completeUploadExternalRequestForm.AddField("channel_id", channelId);
        completeUploadExternalRequestForm.AddField("files", uploadFileArrayString);

        await RequestAsync("files.completeUploadExternal", completeUploadExternalRequestForm, ct);
    }

    /// <summary>
    /// Slackにファイル投稿を行うためのアップロード先のURLと、
    /// ファイルのアップロードを完了させるAPIに渡すファイルIDを取得する
    /// </summary>
    private static async UniTask<(string uploadUrl, string fileId)> GetUploadExternalUrlAndFileIdAsync(
        SlackUploadFile uploadFile, CancellationToken ct)
    {
        var form = CreateBaseForm();
        form.AddField("filename", uploadFile.FileName);
        form.AddField("length", uploadFile.BinaryData.Length.ToString());

        var response = await RequestAsync("files.getUploadURLExternal", form, ct);

        var uploadUrl = (string) response["upload_url"];
        var fileId = (string) response["file_id"];

        return (uploadUrl, fileId);
    }

    /// <summary>
    /// 複数ファイルをアップロードできる処理
    /// 後のfiles.completeUploadExternalのfilesパラメータに渡すために、
    /// アップロードしたファイルのfile_idとtitle（CompleteUploadExternalRequestFile）をリストにして返す
    /// </summary>
    private static async UniTask<List<CompleteUploadExternalRequestFile>> UploadFilesAsync(
        IEnumerable<SlackUploadFile> uploadFiles, CancellationToken ct)
    {
        var completeUploadRequestFiles = new List<CompleteUploadExternalRequestFile>();
        var uploadFileTasks = new List<UniTask>();

        foreach (var uploadFile in uploadFiles)
        {
            var uploadUrlAndFileId =
                await GetUploadExternalUrlAndFileIdAsync(uploadFile, ct);
            var uploadUrl = uploadUrlAndFileId.uploadUrl;
            var fileId = uploadUrlAndFileId.fileId;

            completeUploadRequestFiles.Add(new CompleteUploadExternalRequestFile(fileId, uploadFile.FileName));

            // ファイルアップロード処理をタスクに積む
            uploadFileTasks.Add(UploadFileAsync(uploadFile.BinaryData, uploadUrl));
        }

        // アップロード処理を並列実行し、全てのアップロード処理が完了するまで待つ
        await uploadFileTasks;

        return completeUploadRequestFiles;
    }

    /// <summary>
    /// files.getUploadURLExternalのレスポンスとして返ってくるupload_urlに対してファイルをアップロードする
    /// </summary>
    private static async UniTask UploadFileAsync(byte[] binaryData, string uploadUrl)
    {
        var uploadForm = CreateBaseForm();
        uploadForm.AddBinaryData("file", binaryData);
        using var uploadFileRequest = UnityWebRequest.Post(uploadUrl, uploadForm);
        await uploadFileRequest.SendWebRequest();
    }

    private static WWWForm CreateBaseForm()
    {
        var form = new WWWForm();
        form.AddField("token", AuthToken);
        return form;
    }

    private static async UniTask<Dictionary<string, object>> RequestAsync(
        string apiName,
        WWWForm form,
        CancellationToken ct)
    {
        var stringBuilder = new StringBuilder();
        var requestUrl = stringBuilder.AppendFormat(SlackApiUrlPrefix, apiName).ToString();
        using var request = UnityWebRequest.Post(requestUrl, form);
        await request.SendWebRequest().ToUniTask(cancellationToken: ct);
        var response = JsonConvert.DeserializeObject<Dictionary<string, object>>(request.downloadHandler.text);
        HandleError(response);
        return response;
    }

    /// <summary>
    /// APIリクエストした際のエラーハンドリング
    /// </summary>
    private static void HandleError(UnityWebRequest request)
    {
        if (!string.IsNullOrEmpty(request.error) || request.result != UnityWebRequest.Result.Success)
        {
            throw new SlackException(request.error);
        }
    }

    /// <summary>
    /// APIのレスポンスに対するエラーハンドリング
    /// </summary>
    private static void HandleError(Dictionary<string, object> response)
    {
        if (response == null)
        {
            throw new SlackException("response is null");
        }

        if (response.TryGetValue("ok", out var ok) && (bool) ok)
        {
            // API成功
            return;
        }

        if (response.TryGetValue("error", out var error))
        {
            throw new SlackException((string) error);
        }

        throw new Exception("unknown error");
    }
}
```
</details>


# 実行例
先ほど作成したコードを利用して、試しに画像を投稿してみます。


### 1. Slack Appをワークスペースに追加する
まず、`files:write`の権限を与えたSlack App（ここでは「slack-utility-test」という名前で作成）を、投稿を行いたいチャンネルのワークスペースへ追加します。
今回は、テスト用に新たにアプリを作っていますが、移行する場合については既にアプリがあると思うので、特にすることはありません（必要に応じて権限を調整してください）。

その後、「OAuth & Permissions」タブのBot User OAuth Tokenを確認し、コピーしておきます。
これを先ほどのソースコード内の`AuthToken`として使います。

![oauth-tokens_modified.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3487124/8a1d2239-43c0-6fca-a25d-c2eb91219ef5.png)


### 2. 投稿したいSlackチャンネルへのアプリ追加
投稿したいチャンネルにアプリを追加します。
追加は、Slackアプリをお使いの場合、左下のAppの該当アプリを右クリックして開けるメニューの
「アプリの詳細を表示する」＞「チャンネルにこのアプリを追加する」
で行えます。

![app-detail-and-add-to-channel.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3487124/8905209d-533f-c248-20f6-8e0b564cf962.png)

### 3. 実行コードの作成
Unityで先ほど作成したコードの
`SlackUtility.PostUploadMultipleFilesAsync`関数を呼び出します。
投稿先のチャンネルIDは、チャンネルの詳細ページの下部に記載があります。


```csharp
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class SlackFileUploadTest : MonoBehaviour
{
    private const string ChannelId = "ここに投稿先のチャンネルIDを書く";

    [SerializeField] private Texture2D[] textures;

    void Start()
    {
        var uploadFiles =
            textures.Select(texture => new SlackUtility.SlackUploadFile("TestImage", texture.EncodeToPNG()));
        SlackUtility.PostUploadMultipleFilesAsync(ChannelId, uploadFiles, CancellationToken.None).Forget();
    }
}

```

![slack-channel-id-modified.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3487124/fda1efee-7323-8c98-533b-c26f2eba6154.png)




### 実行結果
実行すると以下のように、該当チャンネルに、目的の画像が投稿されたことが確認できました。
![slack-post-result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3487124/388247be-2577-c305-b663-f4b7bcde346e.png)



## おわりに
今回は、来年3月に廃止が迫るSlackの`files.upload` APIについて、移行対応の例をご紹介しました。
本記事では、新APIでのファイル投稿の具体的な方法に絞って記載したため、更なる細かい制御の部分には触れておりません。
本記事が皆様の移行対応の参考になれば幸いです。