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日をもって廃止されている。

そのため、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にグラフが投稿されている