Google Apps ScriptでGoogle SpreadsheetのグラフをSlackにアップロードする

この記事では、Google Apps Scriptを使って、Google SpreadsheetのグラフをSlackにアップロードする方法を説明する。 Slack Appを作ってBot APIトークンを発行する手順や、OAuthスコープを付与する手順については記載しない。

SlackのファイルアップロードAPI(V2)

Slackのfiles.upload APIは、2025年3月11日をもって廃止されている。

The original web API method for uploading files to Slack, files.upload, is being sunset on March 11, 2025 November 12, 2025.

そのため、Slackのチャンネルにファイルをアップロードするには、次の手順を踏む必要がある。

  1. ファイルをアップロードするためのURLを取得する
    files.getUploadURLExternal
  2. 1.で取得したURLを使ってファイルをアップロードする
  3. アップロードしたファイルとSlackチャンネルを関連付けて投稿する files.completeUploadExternal

これらのAPIに必要なOAuthスコープは、files:writeである。

Google Apps Scriptを使ってSlackにファイルをアップロードする

この記事で紹介するコードは、claspを使って、Google Apps Scriptのプロジェクトを作成し、TypeScriptで開発することを前提としている。
claspのインストール方法や、claspを使ってGoogle Apps Scriptのプロジェクトを作成、デプロイする方法は次の記事を参考にしてほしい。

プロジェクトの作成

Spreadsheetのグラフをアップロードするため、プロジェクトをSpreadsheetに紐づけておく。
Spreadsheetがない場合には、プロジェクトの作成時にspreadsheetのプロジェクトとして作成する。
後からGoogle Apps ScriptのプロジェクトをSpreadsheetに紐づける場合には、.clasp.jsonparentIdにSpreadsheetのIDを指定する。

.clasp.json
{
  "scriptId": "スクリプトID",
  // 省略
  "parentId": ["SpreadsheetのID"]
}

スクリプトプロパティの設定

Google Apps Scriptのスクリプトプロパティには、次の値を設定する。

  • SLACK_API_TOKEN:SlackのBot APIトークンとチャンネルID、スプレッドシートのシート名を設定しておく。
  • SLACK_CHANNEL_ID:SlackのチャンネルID
  • SHEET_NAME:スプレッドシートのシート名

ソースコード:Slackクラスの作成

この記事で紹介するコードは、Slackの処理とメイン処理を、slack.tsindex.tsに分けている。
まずはSlackの処理を行うSlackクラスをslack.tsに作成する。
このクラスでは、次の関数を実装している。

  • ファイルのアップロード用URLを取得するAPIを実行する関数
  • アップロード用で取得したURLを使ってファイルをアップロードする関数
  • アップロードしたファイルとSlackチャンネルを関連付けて投稿する関数
slack.ts
export class Slack {
  private token: string;
  private channelId: string;

  constructor(token: string, channelId: string) {
    this.token = token;
    this.channelId = channelId;
  }

  /**
   * Make a POST request
   * @param url - The API endpoint URL
   * @param payload - The data to send
   * @returns The parsed JSON response
   */
  private post(url: string, payload: object): any {
    const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
      method: "post",
      headers: {
        Authorization: `Bearer ${this.token}`,
        "Content-Type": "application/json",
      },
      payload: JSON.stringify(payload),
      muteHttpExceptions: true,
    };

    const response = UrlFetchApp.fetch(url, options);
    const result = JSON.parse(response.getContentText());

    if (!result.ok) {
      throw new Error(`Request failed: ${result.error} - ${JSON.stringify(result)}`);
    }

    return result;
  }

  /**
   * Make a form-urlencoded POST request
   * @param url - The API endpoint URL
   * @param payload - The form data to send
   * @returns The parsed JSON response
   */
  private postData(url: string, payload: object): any {
    const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
      method: "post",
      headers: {
        Authorization: `Bearer ${this.token}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
      payload: payload,
      muteHttpExceptions: true,
    };

    const response = UrlFetchApp.fetch(url, options);
    const result = JSON.parse(response.getContentText());

    if (!result.ok) {
      throw new Error(`Request failed: ${result.error}`);
    }

    return result;
  }

  /**
   * Get upload URL from Slack API
   * @param filename - Name of the file to upload
   * @param fileSize - Size of the file in bytes
   * @returns Object containing uploadUrl and fileId
   */
  getUploadUrl(filename: string, fileSize: number): { uploadUrl: string; fileId: string } {
    const url = `${this.endpoint}/files.getUploadURLExternal`;
    const payload = {
      filename: filename,
      length: fileSize.toString(),
    };

    const result = this.postData(url, payload);

    return {
      uploadUrl: result.upload_url,
      fileId: result.file_id,
    };
  }

  /**
   * Upload a file to the Slack URL
   * @param uploadUrl - URL to upload the file to
   * @param blob - File blob to upload
   * @param filename - Name of the file
   */
  uploadFile(
    uploadUrl: string,
    blob: GoogleAppsScript.Base.Blob,
    filename: string
  ): void {
    const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
      method: "post",
      payload: {
        file: blob,
        filename: filename,
      },
      muteHttpExceptions: true,
    };

    const response = UrlFetchApp.fetch(uploadUrl, options);

    if (response.getResponseCode() !== 200) {
      throw new Error(`Failed to upload file: ${response.getContentText()}`);
    }
  }

  /**
   * Complete the upload process by associating the file with a channel
   * @param fileId - ID of the uploaded file
   */
  completeUpload(fileId: string): void {
    const url = `${this.endpoint}/files.completeUploadExternal`;
    const payload = {
      files: [
        {
          id: fileId,
          title: fileId,
        },
      ],
      channels: this.channelId,
    };

    this.post(url, payload);
  }
}

ソースコード:メイン処理

SpreadsheetのグラフをSlackにアップロードするメイン処理をindex.tsに実装する。

index.ts
import { Slack } from "./slack";

const scriptProperties = PropertiesService.getScriptProperties();
const token = scriptProperties.getProperty("SLACK_API_TOKEN");
const channelId = scriptProperties.getProperty("SLACK_CHANNEL_ID");
const sheetName = scriptProperties.getProperty("SHEET_NAME");

function main() {
  if (!token || !channelId || !sheetName) {
    throw new Error(
      "Please set SLACK_API_TOKEN, SLACK_CHANNEL_ID, and SHEET_NAME in script properties."
    );
  }

  try {
    const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = spreadsheet.getSheetByName(this.sheetName);
    if (!sheet) {
      throw new Error(`Not found sheet: ${sheetName}`);
    }

    const charts = sheet.getCharts();
    if (charts.length === 0) {
      console.log(`No charts found in the sheet "${sheetName}"`);
      return;
    }

    const client = new Slack(token, channelId);

    charts.forEach((chart, index) => {
      const chartBlob = chart.getAs("image/png");
      const chartName = `chart_${index + 1}_${new Date().getTime()}.png`;

      const fileSize = chartBlob.getBytes().length;

      const { uploadUrl, fileId } = client.getUploadUrl(chartName, fileSize);
      client.uploadFile(uploadUrl, chartBlob, chartName);
      client.completeUpload(fileId);

      console.log(`Uploaded: ${index + 1}/${charts.length}`);
    });
  } catch (error) {
    console.error("Failed to upload image:", error);
  }
}

実行結果

プロジェクトをデプロイしmain関数を実行すると、Slackにグラフがアップロードされる。 スクリーンショット:Slackにグラフが投稿されている