【GAS】Slackでのファイルアップロード方法の変更2(タイムスタンプを取得する。)

当サイトではアフィリエイト広告を利用して商品を紹介しています。

前回の記事(【GAS】Slackでのファイルアップロード方法の変更(files.upload廃止対応版) )ではファイルをアップロードとメッセージの投稿ができました。
しかし、レスポンスとして返ってくるオブジェクトの中身がドキュメントと異なり、このままでは投稿のタイムスタンプが取得できないことに気が付きました。


公式ドキュメントでは返却されるドキュメントの中身の例示は以下になっています。

{
    "id": "F0S43PZDF",
    "created": 1531763342,
    "timestamp": 1531763342,
    "name": "tedair.gif",
    "shares": {
        "public": {
            "C0T8SE4AU": [
                {
                    "reply_users": [
                        "U061F7AUR"
                    ],
                    "reply_users_count": 1,
                    "reply_count": 1,
                    "ts": "1531763348.000001",
                    "thread_ts": "1531763273.000015",
                    "latest_reply": "1531763348.000001",
                    "channel_name": "file-under",
                    "team_id": "T061EG9R6"
                }
            ]
        }
    },
    "channels": [
        "C0T8SE4AU"
    ],
    "groups": [],
    "ims": [],
    "has_rich_preview": false
}

しかし、実際にGASを実行して取得できるオブジェクトは以下の様になっています。

{
    "id": "F0S43PZDF",
    "created": 1531763342,
    "timestamp": 1531763342,
    "name": "tedair.gif",
    "shares": {},
    "channels": [
        "C0T8SE4AU"
    ],
    "groups": [],
    "ims": [],
    "has_rich_preview": false
}

しかし、デバッグで1ステップずつ処理をしていくとなぜか取得ができることもあることに気が付きました。

前回の記事では以下のステップで投稿を行いました。

  1. files.getUploadURLExternalのエンドポイントを使用して、アップロードURLとファイルのIDを取得する
  2. 取得したURLに対してファイルをアップロードする
  3. files.completeUploadExternalのエンドポイントを使用して、ファイルとメッセージを投稿する

推測ではありますが、2のアップロードが完了する前に3を行ってしまうと、実行事態はできる(予約のような状態?)ものの、ファイルは実際にはまだアップロードされてはいないので、sharesの中身が空になってしまうのでは?と考えました。

そこで、なにか方法は無いのか調べていたところfiles.infoのエンドポイントが使えそうでした。

  1. files.getUploadURLExternalのエンドポイントを使用して、アップロードURLとファイルのIDを取得する
  2. 取得したURLに対してファイルをアップロードする
  3. files.completeUploadExternalのエンドポイントを使用して、ファイルとメッセージを投稿する
  4. files.infoのエンドポイントを使用してファイルの状態を取得
  5. 4で取得したオブジェクトからsharesの中身を確認して、空であれば4に戻って再度取得あればタイムスタンプが取得できる

このようにファイルの状態をチェックすることにしてタイムスタンプを得ることができるようになりました

最終的に作成したClassがこちらです。
これで無事にfiles.uploadのエンドポイントが無くなっても大丈夫そうです。

サンプルコード class SlackFileUploader


/**
 * Slackにファイルをアップロードするためのクラス。
 * @class
 */
class SlackFileUploader {
  /**
   * SlackFileUploaderを作成します。
   * @param {string} [token] - Slack APIトークン。省略された場合はプロパティストアから取得します。
   */
  constructor(token) {
    this.token = token || PropertiesService.getScriptProperties().getProperty('BOT_USER_OAUTH_TOKEN');
    if (!this.token) {
      throw new Error('Slack APIトークンが必要です。');
    }
  }

  /**
   * 複数のファイルとコメントをSlackに投稿します。
   * @param {GoogleAppsScript.Base.Blob[]} fileBlobs - アップロードするファイルの配列。
   * @param {string} channelId - 共有するチャンネルのID。
   * @param {string} [initial_comment] - アップロードされたファイルに対する初期コメント(省略可能)。
   * @param {string} [thread_ts] - スレッドのタイムスタンプ(省略可能)。
   */
  post(fileBlobs, channelId, initial_comment, thread_ts) {
    // ファイルIDの配列を生成
    const files = fileBlobs.flatMap(fileBlob => {
      const fileName = fileBlob.getName();
      const fileSize = fileBlob.getBytes().length;
      const uploadUrlData = this.getUploadURL(fileName, fileSize);

      if (!uploadUrlData.ok) {
        Logger.log('Failed to retrieve upload URL');
        return [];
      }

      const uploadResult = this.uploadFile(uploadUrlData.upload_url, fileBlob);
      if (!uploadResult) {
        Logger.log('Failed to upload file');
        return [];
      }
      const fileId = uploadUrlData.file_id
      const fileObject = {
        id: fileId,
        title: fileName
      }
      return fileObject;
    })
    // 全てのファイルが正常にアップロードされた場合のみ、finalizeUploadを呼び出す
    if (files.length > 0) {
      const finalizeResult = this.finalizeUpload(files, channelId, initial_comment, thread_ts);

      if (finalizeResult.ok) {
        // アップロードしたファイルのIDを取得
        const fileIds = finalizeResult.files.map(file => file.id);

        // 各ファイルの共有情報を取得
        const filesInfo = fileIds.map(fileId => {
          return this.getCompleteFileInfo(fileId);
        })

        return {
          finalizeResult: finalizeResult,
          filesInfo: filesInfo
        }

      }
    }
  }

  /**
   * Slackからファイルアップロード用のURLを取得します。
   * @param {string} fileName - アップロードするファイルの名前。
   * @param {number} fileSize - アップロードするファイルのサイズ(バイト単位)。
   * @return {Object} アップロードURLとファイルIDを含むオブジェクト。
   */
  getUploadURL(fileName, fileSize) {
    const url = `https://slack.com/api/files.getUploadURLExternal?token=${encodeURIComponent(this.token)}&filename=${encodeURIComponent(fileName)}&length=${fileSize}`;
    const response = UrlFetchApp.fetch(url, {
      method: 'get',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      muteHttpExceptions: true
    });
    return JSON.parse(response.getContentText());
  }

  /**
   * 指定されたURLにファイルをアップロードします。
   * @param {string} uploadUrl - アップロードするURL。
   * @param {GoogleAppsScript.Base.Blob} fileBlob - アップロードするファイル。
   * @return {string} アップロードのレスポンステキスト。
   */
  uploadFile(uploadUrl, fileBlob) {
    const response = UrlFetchApp.fetch(uploadUrl, {
      method: 'post',
      payload: fileBlob.getBytes(),
      muteHttpExceptions: true
    });
    return response.getContentText();
  }

  /**
   * アップロードを完了し、ファイルを指定されたチャンネルに共有します。
   * @param {Array} files - アップロードされたファイルのIDとオプショナルなタイトルを含むオブジェクトの配列。
   * @param {string} channelId - 共有するチャンネルのID。
   * @param {string} [initial_comment] - アップロードされたファイルに対する初期コメント(省略可能)。
   * @param {string} [thread_ts] - スレッドのタイムスタンプ(省略可能)。
   * @return {Object} ファイルアップロードの最終ステップの結果。
   */
  finalizeUpload(files, channelId, initial_comment, thread_ts) {
    const payload = {
      files: JSON.stringify(files),
    };
    if (channelId) {
      payload.channel_id = channelId;
    }

    if (initial_comment) {
      payload.initial_comment = initial_comment;
    }
    if (thread_ts) {
      payload.thread_ts = thread_ts;
    }

    const response = UrlFetchApp.fetch('https://slack.com/api/files.completeUploadExternal', {
      method: 'post',
      headers: {
        'Authorization': 'Bearer ' + this.token,
        'Content-Type': 'application/json; charset=utf-8'
      },
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    });
    return JSON.parse(response.getContentText());
  }
  /**
   * Slack APIのfiles.infoエンドポイントを呼び出して、ファイルに関する情報を取得します。
   * @param {string} fileId - 取得するファイルのID。
   * @return {Object} ファイルに関する情報を含むオブジェクト。
   */
  getFileInfo(fileId) {
    const url = `https://slack.com/api/files.info?token=${encodeURIComponent(this.token)}&file=${encodeURIComponent(fileId)}`;
    const response = UrlFetchApp.fetch(url, {
      method: 'get',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      muteHttpExceptions: true
    });
    return JSON.parse(response.getContentText());
  }
  /**
   * ファイルの共有情報が取得されるまでリトライし、ファイル情報オブジェクトを返します。
   * @param {string} fileId - ファイルのID。
   * @param {number} maxRetries - 最大リトライ回数。
   * @param {number} delayMs - リトライ間隔(ミリ秒)。
   * @returns {Object|null} - ファイル情報オブジェクト、またはnull。
   */
  getCompleteFileInfo(fileId, maxRetries = 30, delayMs = 500) {
    let retryCount = 0;
    let fileInfo = this.getFileInfo(fileId);

    while ((!fileInfo.file.hasOwnProperty('shares') || !fileInfo.file.shares.hasOwnProperty('public')) && retryCount < maxRetries) {
      Utilities.sleep(delayMs);
      fileInfo = this.getFileInfo(fileId);
      retryCount++;
    }

    if (fileInfo.file.hasOwnProperty('shares') && fileInfo.file.shares.hasOwnProperty('public')) {
      return fileInfo;
    } else {
      Logger.log(`ファイルの共有情報が見つかりません (fileId: ${fileId})`);
      return null;
    }
  }
}

参考

file

files.info

Google Apps Script

Posted by sochan