Google Apps Script (GAS) で Slack 連携を実装する前に知っておくとよい 5 つのこと

Slack 連携、特にちょっとした通知を Google Apps Script (GAS) からやりたいというユースケースは非常によく聞かれます。本格的なアプリケーション動作環境などを用意することなく Google Workspace (旧 G Suite) のアカウントだけでちょっとした自動化をできるので、重宝しますよね。

ちなみに GAS の正式名称は「Google App Script」ではなく「Google **Apps** Script」です。たまに誤記を見かけます... :sweat_smile: 

## Slack 連携で知っておくとよい 5 つのこと

さて、本題です。

Slack アプリを Google Apps Script (GAS) で実装する場合、特にインタラクティブな機能の利用において知っておくべき制約があります。この記事では、以下の 5 つの留意点について解説します。ログの有効化など Slack アプリ開発に限らない内容も含まれていますが、運用視点で入れています。

* Web API はシンプルなコードを使いましょう
* トークンなどはスクリプトプロパティに保持しましょう
* Logging を有効にした GCP プロジェクトをつくって切り替えましょう
* Web App では Verification Token をチェックしましょう
* Web App では 3 秒タイムアウトに注意しましょう

### Web API はシンプルなコードを使いましょう

よくあるシンプルな Slack 連携は通知を行うものなので [230 を超える Slack Web API](https://api.slack.com/methods) の中でも特に [chat.postMessage](https://api.slack.com/methods/chat.postMessage) や [files.upload](https://api.slack.com/methods/files.upload) を使うことが多いでしょう。

ほとんど全てのエンドポイントは以下のようにして呼び出すことができます。API ドキュメントでは推奨される HTTP メソッドが書かれていますが、実際には全て POST メソッドで送信して構いませんので、以下のようなシンプルなメソッドを流用して OK です。

```js
function callWebApi(token, apiMethod, payload) {
  const params = {};
  Object.assign(params, payload);
  for (const [key, value] of Object.entries(params)) {
    if (typeof value === "object") {
      params[key] = JSON.stringify(value);
    }
  } 
  const response = UrlFetchApp.fetch(
    `https://www.slack.com/api/${apiMethod}`,
    {
      method: "post",
      contentType: "application/x-www-form-urlencoded",
      headers: { "Authorization": `Bearer ${token}` },
      payload: params,
    }
  );
  console.log(`Web API (${apiMethod}) response: ${response}`)
  return response;
}
```

この関数を使った chat.postMessage の呼び出し例は、以下のようなコードになります。

```js
const token = PropertiesService.getScriptProperties().getProperty("SLACK_BOT_TOKEN");
const apiResponse = callWebApi(token, "chat.postMessage", {
  channel: "#random",
  text: ":wave: こんにちは！"
});
```

~~ファイルアップロードに関しては、以下のようなコードで対応できるでしょう。~~

**[2024/05 更新]:** 方式が変更になりますので、以下の記事を参考にしてください：

https://tech.enechange.co.jp/entry/2024/05/15/213517

```js
function uploadFileToSlack(token, payload) {
  const endpoint = "https://www.slack.com/api/files.upload";
  if (payload["file"] !== undefined) {
    payload["token"] = token;
    const response = UrlFetchApp.fetch(endpoint, { method: "post", payload: payload });
    console.log(`Web API (files.upload) response: ${response}`)
    return response;
  } else {
    const response = UrlFetchApp.fetch(endpoint, {
      method: "post",
      contentType: "application/x-www-form-urlencoded",
      headers: { "Authorization": `Bearer ${token}` },
      payload: payload,
    });
    console.log(`Web API (files.upload) response: ${response}`)
    return response;
  }
}
```

以下のようにテキストであれば `content` を使ってもよいですし

```js
const token = PropertiesService.getScriptProperties().getProperty("SLACK_BOT_TOKEN");
const response = uploadFileToSlack(token, {
  channels: 'C111', // 複数渡すならカンマ区切りで
  title: 'content によるアップロード例',
  content: 'Hello World!',
  filename: 'sample.txt',
  // https://api.slack.com/types/file#file_types
  filetype: 'text'
});
```

画像などであれば `file` に Blob オブジェクトを渡せばアップロードすることができるでしょう。

```js
const token = PropertiesService.getScriptProperties().getProperty("SLACK_BOT_TOKEN");
const blob = UrlFetchApp.fetch("https://www.example.com/image.png").getBlob();
const response = uploadFileToSlack(token, {
  title: 'file によるアップロード例',
  channels: 'C111,C222,C333',
  file: blob,
});
```

さて、上記のコード例に何度も `PropertiesService` というものが出てきました。これについて次のセクションで説明します。

### トークンなどはスクリプトプロパティに保持しましょう

`xoxb-` や `xoxp-` で始まる Slack の OAuth トークンや、`https://hooks.slack.com/***` のような [Incoming Webhooks](https://api.slack.com/messaging/webhooks) の URL は直接コードに埋め込まずにスクリプトプロパティから読み出すようにしましょう。

これをやるべき理由は、実際に使い始めた GAS のコードは git リポジトリなどの方法でバージョン管理するかと思いますが（もししていないなら、何らかの方法で管理しましょう！）、そのソースコードにこのような秘匿すべき情報が含まれていると、意図しない漏洩などにつながるリスクがあるためです。

このスクリプトプロパティの設定方法ですが、現行のスクリプトエディタの GUI からだと設定ができなくなってしまっているようです。プロパティを設定するために、画面の右上にある「以前のエディタを使用」で一時的に旧来のエディタに切り替えましょう。英語で使っている方もここからの説明のメニューの位置等は同様です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/2859ba35-75ff-2168-acde-c09d0ca93dfd.png" width=600>
次に「ファイル」から「プロジェクトのプロパティ」へ移動し
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/b3d1f80e-dae7-0f07-e651-0f104c99daf7.png" width=250>
「スクリプトのプロパティ」タブに移動すると設定することができます。プロパティを設定したら GAS のコード内で `PropertiesService` を使って参照するようにしましょう。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/29a11867-e778-571c-2b0c-eebdaed33b8b.png" width=400>

設定が終わったら、現行のエディタに戻しましょう。

### Logging を有効にした GCP プロジェクトをつくって切り替えましょう

GUI 上で関数単位で実行すると実行ログが画面上に表示されるかと思います。しかし、いざ実際のイベントをトリガーにして実行するように切り替えたところ `console.log` で出力しておいた情報が閲覧できない！と困ったことがある（or 現在進行形で困っている）方もいるかもしれません。

GCP プロジェクトを新しく作って、プロジェクトの設定を切り替える必要があります。以下のメニューから「プロジェクトの設定」に移動し、
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/8ca5cf71-7736-7f49-62b7-655a1f5218ab.png" width=400 />
以下のように自分で新しく作成して Cloud Logging の機能を有効化した Google Cloud Platform (GCP) プロジェクトのプロジェクト番号を紐づけるようにしてください。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/1cfc41d1-017d-9ccb-7a01-6f75cee0dab7.png" width=400 />
なお、設定する前のデフォルトの設定では、プロジェクト番号が設定されていないはずです。この状態だと自分で出力したログを閲覧することはできません。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/21c7a589-deb7-6f35-b30c-ae7fb285e6bb.png" width=400 />


### Web App では Verification Token をチェックしましょう

（ここから少し長い説明になります... :bow:）

ここから先は実際にやる人はさほど多くないとは思いますが、ボタンクリック・スラッシュコマンド起動・ショートカット実行・モーダル操作への応答、イベント API の活用をどうしても GAS でやりたいという方向けのアドバイスです。

これらのよりインタラクティブな機能を使う場合：

* [ソケットモード](https://qiita.com/seratch/items/1a460c08c3e245b56441)を使って WebSocket 接続して双方向のやりとりを行う
* 公開 HTTP エンドポイントを用意して、そこに Slack からペイロードを送信するよう設定する

という、二通りのやり方があります。GAS でやるなら WebSocket のコネクションを維持するわけにはいかないので、後者ということになるでしょう。これは GAS の Web App を作り、その URL を Slack アプリの設定画面の **Request URL** に指定することで実現することができます。

#### GAS の Web App では X-Slack-Signature を検証できません

この公開された HTTP エンドポイントへの通信、URL さえ知っていれば第三者がリクエストを送信することが技術的には可能です。そのため Slack からのリクエストでは「本当に Slack からのリクエストなのか」「リクエスト内容の改竄が行われていないか」を検証することを目的として、全てのペイロード送信には `X-Slack-Signature` と `X-Slack-Request-Timestamp` というリクエストヘッダーが常に含まれています。これらを検証することは、安全な Slack アプリ運用において非常に重要です。

Slack の[公式 SDK](https://github.com/slackapi) やそれをベースにした [Bolt フレームワーク](https://api.slack.com/tools/bolt) では、この署名の検証があらかじめ組み込まれています。もし GAS ではない既存の Slack アプリでこれらを検証していない場合は、これらの SDK を活用するか、公式 SDK が未対応の言語の場合は[こちらのドキュメント](https://api.slack.com/authentication/verifying-requests-from-slack)に記載されている検証ロジックを実装するようにしてください。

GAS に話を戻します。**GAS の Web App ではリクエストヘッダーにアクセスすることができません**。これは「**上記のようなリクエストの検証を行えない**」ということを意味します。この制約からインタラクティブな Slack アプリを GAS で実装することは基本的におすすめしないのですが、「すでに運用しているものがあって、すぐに別の方式に移行するのは難しい」という場合もあるかもしれません。そのような事情があるときは、せめて Verification Token を検証するようにしてください。

#### 代替案としての Verification Token 利用

Verification Token とは `X-Slack-Signature` 導入前から存在するレガシーなリクエスト検証の方式です。この値は Slack アプリの管理画面の **Basic Information** のページで **Verification Token** という名前で見つけることができます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/2c498adc-959e-3c43-5f0e-e4bc94d85f8c.gif" width=400 />
この値をスクリプトプロパティに設定し、それを使って以下のようにリクエストを検証するようにしましょう。

検証部分だけ見てもよくわからないと思いますので、実装の雛形とともに紹介しますが、`// Verification Token の検証` というコメントがついているところに注目してください。

```js
// スクリプトプロパティから Verification Token を読み込む
const legacyVerificationToken = PropertiesService.getScriptProperties().getProperty("SLACK_VERIFICATION_TOKEN");

// Slack からの POST リクエストをハンドリングする関数
function doPost(e) {
  // 必要ならログ出力
  console.log(`Incoming form request data: ${JSON.stringify(e.postData)}`);

  if (typeof e.postData === "undefined") {
    return ack("invalid request");
  }

  if (e.postData.type === "application/json") {
    // ----------------------------
    // Events API (イベント API / URL 検証リクエスト)
    // ----------------------------

    const payload = JSON.parse(e.postData.getDataAsString());
    if (payload.token !== legacyVerificationToken) {
      // Verification Token の検証
      console.log(`Invalid verification token detected (actual: ${payload.token}, expected: ${legacyVerificationToken})`);
      return ack("invalid request");
    }
    if (typeof payload.challenge !== "undefined") {
      // Events API を有効にしたときの URL 検証リクエストへの応答
      return ack(payload.challenge);
    }
    console.log(`Events API payload: ${JSON.stringify(payload)}`);

    // -------------------------------------------------------------
    // TODO: ここにあなたの処理を追加します
    if (typeof payload.event.channel !== "undefined") {
      // チャンネル内で発生したイベントのときにメッセージを投稿するサンプル例
      callWebApi(token, "chat.postMessage", {
        channel: payload.event.channel,
        text: "Hi there!"
      });
    }
    // -------------------------------------------------------------
    // 200 OK を返すことでペイロードを受信したことを Slack に対して伝える
    return ack("");

  } else if (e.postData.type === "application/x-www-form-urlencoded") {
    if (typeof e.parameters.payload !== "undefined") {
      // ----------------------------
      // Interactivity & Shortcuts (ボタン操作やモーダル送信、ショートカットなど)
      // ----------------------------

      const payload = JSON.parse(e.parameters.payload[0]);
      if (payload.token !== legacyVerificationToken) {
        // Verification Token の検証
        console.log(`Invalid verification token detected (actual: ${payload.token}, expected: ${legacyVerificationToken})`);
        return ack("invalid request");
      }
      console.log(`Interactivity payload: ${JSON.stringify(payload)}`);

      // -------------------------------------------------------------
      // TODO: ここにあなたの処理を追加します
      if (payload.type === "shortcut") {
        if (payload.callback_id === "gas") {
          // Callback ID が gas のグローバルショートカットへの応答としてモーダルを開く例
          callWebApi(token, "views.open", {
            trigger_id: payload.trigger_id,
            user_id: payload.user.id,
            view: JSON.stringify(modalView)
          });
        }
      } else if (payload.type === "message_action") {
        if (payload.callback_id === "gas-msg") {
          // Callback ID が gas-msg のメッセージショートカットへの応答として
          // response_url を使って返信を投稿する例（respond のコード例は次のコード例を参照）
          respond(payload.response_url, "Thanks for running a message shortcut!");
        }
      } else if (payload.type === "block_actions") {
        // Block Kit (message 内の blocks) 内のボタンクリック・セレクトメニューのアイテム選択イベント
        console.log(`Action data: ${JSON.stringify(payload.actions[0])}`);
      } else if (payload.type === "view_submission") {
        if (payload.view.callback_id === "modal-id") {
          // モーダルの submit ボタンを押してデータ送信が実行されたときのハンドリング
          const stateValues = payload.view.state.values;
          console.log(`View submssion data: ${JSON.stringify(stateValues)}`);
          // 空のボディで応答したときはモーダルを閉じる
          // response_action で errors / update / push など指定も可能
          return ack("");
        }
      }
      // -------------------------------------------------------------

    } else if (typeof e.parameters.command !== "undefined") {
      // ----------------------------
      // Slash Commands (スラッシュコマンドの実行)
      // ----------------------------

      const payload = {}
      for (const [key, value] of Object.entries(e.parameters)) {
        payload[key] = value[0];
      }
      if (payload.token !== legacyVerificationToken) {
        // Verification Token の検証
        console.log(`Invalid verification token detected (actual: ${payload.token}, expected: ${legacyVerificationToken})`);
        return ack("invalid request");
      }
      console.log(`Slash command payload: ${JSON.stringify(payload)}`);

      // -------------------------------------------------------------
      // TODO: ここにあなたの処理を追加します
      if (payload.command === "/gas") {
        // "/gas" というスラッシュコマンドのときの処理
        return ack("Hi there!");
      }
      // -------------------------------------------------------------
    }

  }
  // 200 OK を返すことでペイロードを受信したことを Slack に対して伝える
  return ack("");
}

// 200 OK を返すことでペイロードを受信したことを Slack に対して伝えます
// 基本的には空のボディで応答しますが、インタラクションの種類によっては
// ボディに何らかの情報を含めることができます
function ack(payload) {
  if (typeof payload === "string") {
    // これは "" かチャンネル内でのシンプルなメッセージ応答のパターンです
    return ContentService.createTextOutput(payload);
  } else {
    // チャンネル内での応答の場合はボディに返信メッセージ内容を含めることができます
    // view_submission への応答の場合は response_action などを含めることもできます
    return ContentService.createTextOutput(JSON.stringify(payload));
  }
}
```

このコード例からもわかるように Verification Token は固定の値で、リクエストペイロードに `token` という名前で常に含まれるものです。`X-Slack-Signature` と同等の安全なソリューションではないことにご注意ください。

また、ついでに response_url を使うためのサンプルコードも紹介しておきます。response_url について知りたい方は、ぜひこちらの記事を読んでみてください: [Slack ペイロードに含まれる response_url を完全に理解する](https://qiita.com/seratch/items/ed29acd565af36e65072)

```js
function respond(responseUrl, payload) {
  var responseBody = payload;
  if (typeof payload === "string") {
    responseBody = { "text": payload };
  }
  const response = UrlFetchApp.fetch(responseUrl, {
    method: "post",
    contentType: "application/json; charset=utf-8",
    payload: JSON.stringify(responseBody),
  }
  );
  console.log(response);
}
```

### Web App では 3 秒タイムアウトに注意しましょう

上記のコード例、正常系の場合に、処理の最後に 200 OK の応答を返していますね。

もし `TODO: ここにあなたの処理を追加します` の部分の処理が Slack の API を複数呼び出したり、Google の API を使ったりして 3 秒以上かかってしまう場合、イベント API であれば Slack からの同一イベントの再送処理が発生します。インタラクティブな操作への応答であれば、エンドユーザーに対してタイムアウトした旨を伝えるエラーメッセージが表示されることになります。

```js
if (e.postData.type === "application/json") {
  const payload = JSON.parse(e.postData.getDataAsString());
  if (payload.token !== legacyVerificationToken) {
    // Verification Token の検証
    return ack("invalid request");
  }
  // -------------------------------------------------------------
  // TODO: ここにあなたの処理を追加します
  if (typeof payload.event.channel !== "undefined") {
    callWebApi(token, "chat.postMessage", {
      channel: payload.event.channel,
      text: "Hi there!"
    });
  }
  // -------------------------------------------------------------
  // 200 OK を返すことでペイロードを受信したことを Slack に対して伝える
  return ack("");
}
```

これを防ぐためには：

* [GAS のドキュメントでも書かれているように](https://developers.google.com/apps-script/guides/libraries)外部ライブラリの読み込みは極力避ける
* 確実に 3 秒以内に処理が終わるよう小さい処理に分割する（一旦受け付けた処理を Google Sheets なりに書き出して時間がかかる後続の処理は非同期で実行する）

といった工夫をするとよいかと思います。

### 最後に

以上です！私が知る限り、このような情報はあまりまとまっていなかったと思いますので、書いてみました。何かのお役に立てば幸いです。

私個人のおすすめとしては、GAS でやる範囲は、あくまでシンプルな通知程度の連携にとどめて、本格的なアプリを運用する必要が出てきたら [Bolt](https://api.slack.com/tools/bolt) を使って開発したアプリを Google Cloud 上で稼働させるのがよいのではないかと思います。

何かご質問などありましたら、この記事のコメント欄でもいいですし、コミュニティの Slack ワークスペースに参加して、[日本語話者のチャンネル #lang-japanese](https://community.slack.com/archives/CL9L0NRFS)で質問してみてください！ワークスペースにまだ参加されていない方は https://bit.ly/join-us-on-slack-2 から参加することができます。

それではまた！ :wave: