ENECHANGE Developer Blog

ENECHANGE開発者ブログ

Slackのfiles.upload API廃止に備え、Google Apps Scriptのコードに手を入れる

ENECHANGE VPoTの岩本 (iwamot) です。

2024年4月~6月の四半期から、社内I/O Dayを試験的に始めています。「プロダクト開発や事業課題の解決に活用できそうな技術について、集中的にインプット/アウトプットする日を宣言する制度」のことです(くわしくは別の機会にお伝えします)。

多くのエンジニアが本日5月15日にI/O Dayを宣言していたので、ぼくも乗っかってみました。お祭り感があって、いいですね。

ぼくが取り組んだテーマは、Slackのfiles.upload API廃止対応でした。2025年3月までに、新しいAPIに移行する必要があるんですよね。まだ時間はあるものの、ボリューム的にちょうどよさそうなので選びました。

今回の記事では、Google Apps Scriptからの呼び出し方をどう変えたのかご紹介します。これから廃止対応を進める方の参考になれば幸いです。

変更前のコード

まず、変更前のコードをご紹介します*1。とあるクラスの uploadFile メソッドです。files.upload APIをシンプルに呼んでいます。

  /**
   * ファイルをアップロードする
   *
   * @param {Blob} fileBlob - ファイル
   * @param {String} filename - ファイル名
   * @return {String} アップロードしたファイルURL
   */
  uploadFile(fileBlob, filename) {
    const options = {
      payload: {
        token: this.oauthToken,
        file: fileBlob,
        filename: filename,
      },
    };

    const response = UrlFetchApp.fetch(
      "https://slack.com/api/files.upload",
      options
    );
    const responseBody = response.getContentText();
    const data = JSON.parse(responseBody);
    if (!data.ok) {
      throw new Error(data);
    }
    return data.file.permalink;
  }

新しいアップロード手順

新しいAPIでのファイルアップロード手順は、下記のとおりです。

  1. files.getUploadURLExternal API を呼び出してファイルアップロードに使用する https://files.slack.com/upload/v1/... URL とファイル ID を取得する
  2. https://files.slack.com/upload/v1/... URL に POST リクエストを送信してファイルをアップロードする
  3. files.completeUploadExternal API にファイル ID に加えて チャンネル ID などを送信して、アップロード処理を完了させる

qiita.com

変更後のコード

変更後のコードは下記のようになりました。新しいアップロード手順を素直に実装した形です。クラスにメソッドを3つ追加しています。

  /**
   * ファイルをアップロードする
   *
   * @param {Blob} fileBlob - ファイル
   * @param {String} filename - ファイル名
   * @return {String} アップロードしたファイルURL
   */
  uploadFile(fileBlob, filename) {
    const [fileUploadUrl, fileId] = this.getFileUploadUrl(
      filename,
      fileBlob.getBytes().length
    );

    const options = {
      method: "post",
      payload: fileBlob,
    };
    const response = UrlFetchApp.fetch(fileUploadUrl, options);
    Logger.log(response);

    this.completeFileUpload(fileId);
    return this.getFilePermalink(fileId);
  }

  /**
   * ファイルアップロード用URLを取得する
   *
   * @param {String} filename - ファイル名
   * @param {Number} length - ファイルサイズ
   * @return {String[]} ファイルアップロード用URL、ファイルID
   */
  getFileUploadUrl(filename, length) {
    const options = {
      method: "get",
      headers: {
        Authorization: "Bearer " + this.oauthToken,
      },
      payload: {
        filename: filename,
        length: length.toString(),
      },
    };

    const response = UrlFetchApp.fetch(
      "https://slack.com/api/files.getUploadURLExternal",
      options
    );
    Logger.log(response);
    const responseBody = response.getContentText();
    const data = JSON.parse(responseBody);
    if (!data.ok) {
      throw new Error(data);
    }
    return [data.upload_url, data.file_id];
  }

  /**
   * ファイルアップロード処理を完了する
   *
   * @param {String} fileId - ファイルID
   */
  completeFileUpload(fileId) {
    const options = {
      method: "post",
      headers: {
        Authorization: "Bearer " + this.oauthToken,
      },
      contentType: "application/json",
      payload: JSON.stringify({
        files: [{ id: fileId }],
      }),
    };

    const response = UrlFetchApp.fetch(
      "https://slack.com/api/files.completeUploadExternal",
      options
    );
    Logger.log(response);
    const responseBody = response.getContentText();
    const data = JSON.parse(responseBody);
    if (!data.ok) {
      throw new Error(data);
    }
  }

  /**
   * ファイルのURLを取得する
   *
   * @param {String} fileId - ファイルID
   * @return {String} ファイルのURL
   */
  getFilePermalink(fileId) {
    const options = {
      method: "get",
      headers: {
        Authorization: "Bearer " + this.oauthToken,
      },
      payload: {
        file: fileId,
      },
    };

    const response = UrlFetchApp.fetch(
      "https://slack.com/api/files.info",
      options
    );
    Logger.log(response);
    const responseBody = response.getContentText();
    const data = JSON.parse(responseBody);
    if (!data.ok) {
      throw new Error(data);
    }
    return data.file.permalink;
  }

いくつか補足します。

整数化はtoStringで

  getFileUploadUrl(filename, length) {
    const options = {
      method: "get",
      headers: {
        Authorization: "Bearer " + this.oauthToken,
      },
      payload: {
        filename: filename,
        length: length.toString(),

lengthを文字列に変換しているのは、数値型のままだと 12345.0 のような値がPOSTされ、エラーになるためです。

配列を含むペイロードはJSON.stringifyで

  completeFileUpload(fileId) {
    const options = {
      method: "post",
      headers: {
        Authorization: "Bearer " + this.oauthToken,
      },
      contentType: "application/json",
      payload: JSON.stringify({
        files: [{ id: fileId }],
      }),
    };

JSON.stringifyしているのは、files.completeUploadExternal APIのペイロードで配列を指定するのに必要なためです。contentTypeにも要注意。くわしくは下記の記事をご参照ください。

qiita.com

また、トークンをヘッダに移動したのは、JSONペイロード内で渡しても無視されてしまうためです。

トークンはヘッダに含めるようドキュメントでも推奨されています。

We prefer tokens to be sent in the Authorization HTTP header of your outbound requests.

api.slack.com

files.info APIを呼ぶならfiles.readの許可を

  getFilePermalink(fileId) {
    const options = {
      method: "get",
      headers: {
        Authorization: "Bearer " + this.oauthToken,
      },
      payload: {
        file: fileId,
      },
    };

    const response = UrlFetchApp.fetch(
      "https://slack.com/api/files.info",
      options
    );

多くのユースケースでは、アップロードしたファイルのURLが必要になることでしょう。今回のコードもそうでした。

ファイルのURLを取得するには、files.info APIが使えます(他の方法もあるかもしれませんが、調べていません)。

ただし、files.info APIを呼び出すには files.read スコープが必要です。files.write だけではエラーになります。

おわりに

以上、Slackのfiles.upload API廃止に備えて、Google Apps Scriptのコードに手を入れた例をご紹介しました。

Slack APIもGoogle Apps Scriptも不慣れでしたが、社内I/O Dayを活用することで、コードリーディングからブログ投稿まで1日で終えられました。

次の四半期でも、また何か取り組めたらと思っています。

追記 (2024-05-16)

HTTPメソッドを明示的に指定するようコードを変更しました。記事公開時には method: "post" などの指定をしていませんでした。

瀬良さん、フィードバックありがとうございました!

*1:記事内のコードはすべてMITライセンスです