Open67

GAS で Slack アプリ作る

以下を整理する

  • Typescript + 3rd party npm module 利用で GAS を作るときの構成
  • GAS で Slack アプリを作るときの構成

GAS は本番環境とテスト環境分ける

まずは clasp 入れてログイン。

$ npm i -D @google/clasp
$ npx clasp login

開発用と本番用それぞれスクリプト作る。
.env で参照できるようにする。

$ mkdir config
$ npx clasp create --title "prd" --type api
$ mv .clasp.json prd.clasp.json
$ npx clasp create --title "dev" --type api
$ mv .clasp.json dev.clasp.json
$ cat prd.clasp.json | jq -r '"APP_SCREPT_ID=" + .scriptId' > .env.production
$ cat dev.clasp.json | jq -r '"APP_SCREPT_ID=" + .scriptId' > .env.development
$ rm *.clasp.json
$ echo ".env.*" >> .gitignore
$ echo "!.env.example" >> .gitignore
$ echo "APP_SCREPT_ID=<scriptId>" > .env.example

https://github.com/sterashima78/slack-app-gas-ts/tree/967ba7af50f3d7654841fbe41a18231e5ca74f3d

もろもろ入れる

$ npm i -D cross-env ts-node typescript ts-loader webpack \
    webpack-cli gas-webpack-plugin copy-webpack-plugin \
    @types/google-apps-script @types/node \
    @types/copy-webpack-plugin npm-run-all dotenv

とりあえず簡単な実装とビルド設定を書いた。この状態でデプロイまでできるはず。

https://github.com/sterashima78/slack-app-gas-ts/tree/04ae8facb798d31ff5bda2d41643c0d174989091

途中で Web エディタとか触ったから念の為頭からやり直したいところ

初回はデプロイを作成する必要があるから、npx clasp deploy する。

以降は取得したデプロイIDを指定してデプロイする必要がある (URLがかわる)。

以降は取得したデプロイIDを指定してデプロイする必要がある (URLがかわる)。

ここで対応

https://github.com/sterashima78/slack-app-gas-ts/tree/f37699d7fe96efc5b7fca7b8fb91f95a20984a1d

新しいスクリプト作るときに以下を実行して Script ID と Deploy ID をそれぞれ .env.* に記述する。

$ npx clasp create --type webapp
$ npx clasp deploy

正直これをラップするスクリプトを書いてしまいたい (後で書く)。

リクエスト先の URL は https://script.google.com/macros/s/<Deploy ID>/exec となる

GitHub Actions や unit test / lint らは後回し。 clasp deploy などの認証情報に関することを除けばいつもと同じなので。

リンター + フォーマッタ

$ npx eslint --init
✔ How would you like to use ESLint? · problems
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · Yes
✔ Where does your code run? · browser
✔ What format do you want your config file to be in? · JavaScript
Local ESLint installation not found.
The config that you've selected requires the following dependencies:

@typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest eslint@latest
✔ Would you like to install them now with npm? · Yes

ここに prettier 足す

$ npm i -D prettier eslint-config-prettier
.eslintrc.js
// eslint-disable-next-line no-undef
module.exports = {
    "env": {
        "browser": true,
        "es2021": true
    },
    "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        'prettier',
    ],
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
        "ecmaVersion": 12,
        "sourceType": "module"
    },
    "plugins": [
        "@typescript-eslint"
    ],
    "rules": {
    }
};

npm scripts 足しておく

    "lint": "eslint \"src/**/*\" --ignore-path .gitignore --ext .js --ext .ts",
    "type-check": "tsc --noEmit --skipLibCheck",

ユニットテスト

$ npm i -D jest ts-jest @types/jest
$ npx jest --init
The following questions will help Jest to create a suitable configuration for your project

✔ Would you like to use Jest when running "test" script in "package.json"? … yes
✔ Would you like to use Typescript for the configuration file? … yes
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … yes
✔ Which provider should be used to instrument code for coverage? › v8
✔ Automatically clear mock calls and instances between every test? … no
$ npx ts-jest config:init

jest.config.tspreset: 'ts-jest' を足す

動作確認。

src/example.test.ts
describe("test", () => {
    test("test", () => {
        expect(1 + 2).toBe(3)
    })
})
$ npm test
> jest

 PASS  src/example.test.ts
  testtest (2 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 |                   
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.84 s
Ran all test suites.

lint-staged

$ npx mrm@2 lint-staged
lint-staged.config.js
module.exports = {
  "**/*.ts": (filename) => ["tsc -p tsconfig.json --noEmit", "eslint"],
};
  • コマンド名
  • Slack からリクエストが飛んでいく先
    • https://script.google.com/macros/s/<Deploy ID>/exec
  • コマンドの説明
  • コマンドに渡すパラメータ

ページの下側に実際にコマンドを使おうとしたときのプレビューが出る

作成されたコマンドが表示される

スラッシュコマンドに応答してメッセージを書き込みたいので書き込み権限を与える。

OAuth & Permissionsをひらく

botアカウントがいるチャンネルと、パブリックチャンネルへの書き込み権限をつける

特定の Workspaceにインストールする

意図したワークスペースであることを確認して許可する。

Bot Token 周りのやつ

方針は

  • トークンは .env.* ファイルに書く
  • PropertyService を使って .env.* ファイルの内容を登録するスクリプトを用意する
  • コード内でも PropertiesService を使ってトークンを取得する

clasp run を実行するために追加の設定が必要そう

一回の設定のためにほかの設定をいろいろやるのはコスパがわるい

GAS プロジェクトは公開しないのだから、ビルド時にスクリプトへトークンを埋め込んでも問題ないはずだな。
回避すべきなのは、ソースコードにトークンを埋め込むことであって、デプロイ後のソースコードにアクセスができるような状態なら、 PropertiesService を使っても使わなくてもトークンは取られる。

方針を変える

  • トークンは .env.* ファイルに書く
  • ビルド時に webpack でバンドル後のソースにトークンを埋め込む
    • GAS プロジェクト自体の共有設定に注意する

インタラクティブなやつ作る

アプリの設定画面から Interactivity & Shortcuts にアクセスして有効にする。

URLは新しいスクリプトを作ればそれでいいが、今回はペイロードに応じて応答を変更する感じにしようとおもうので、同じ URL にしておく

新しいショートカットを追加する

質問受付アプリみたいなのにしたいから、グローバルにする。

ショートカット名などを入力する。

Callback ID はメッセージの特定に使う。

以下で、

{
	"blocks": [
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
				"text": "こんにちは!\n問い合わせの種類を以下から選択してください!"
			}
		},
		{
			"type": "divider"
		},
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
				"text": "新規開発に関する質問"
			},
			"accessory": {
				"type": "button",
				"text": {
					"type": "plain_text",
					"text": "質問する",
					"emoji": true
				},
				"value": "develop",
				"action_id": "question_develop"
			}
		},
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
				"text": "バグ報告"
			},
			"accessory": {
				"type": "button",
				"text": {
					"type": "plain_text",
					"text": "バグを報告",
					"emoji": true
				},
				"value": "bug",
				"action_id": "question_bug"
			}
		}
	]
}

以下のような表示になる

とりあえず動くのを作った。
あとは実装をもうちょっとパターン化すればよさそう。

適当に書いていると、コメントのぶら下げ先を間違えるな。
あるトピックの下に返信でぶら下げていったほうが後で見やすいのに。

後で本か記事にするからええか。

ユーティリティ関数といくつか例ができれば良さそうだな

Qiitaの記事を参考によく使うであろう関数を定義。
型付けはやる気ないけどまぁいい。

/**
 * Slack API をコールする
 */
export const slackApi =
  (token: string) => (apiMethod: string) => (payload: object) =>
    UrlFetchApp.fetch(`https://www.slack.com/api/${apiMethod}`, {
      method: "post",
      contentType: "application/x-www-form-urlencoded",
      headers: { Authorization: `Bearer ${token}` },
      payload: payload,
    });

/**
 * response_url を使って応答するとき
 */
export const respond = (responseUrl: string, payload: object | string) =>
  UrlFetchApp.fetch(responseUrl, {
    method: "post",
    contentType: "application/json; charset=utf-8",
    payload: JSON.stringify(
      typeof payload === "string" ? { text: payload } : payload
    ),
  });

/**
 * Slack に応答する
 */
export const ack = (payload: object | string) =>
  ContentService.createTextOutput(
    typeof payload === "string" ? payload : JSON.stringify(payload)
  );

以下でリクエストごとに何をするかを登録できる

export type Command = Record<
  string,
  (payload: any) => GoogleAppsScript.Content.TextOutput
>;

export type Commands = {
  channelEvent?: Command;
  globalShortcut?: Command;
  messageShortcut?: Command;
  blockAction?: Command;
  viewSubmission?: Command;
  slashCommand?: Command;
};
const parseRequest = (e: GoogleAppsScript.Events.DoPost) => {
  if (e.postData.type === "application/json") {
    return JSON.parse(e.postData.contents);
  } else if (typeof e.parameters.command !== "undefined") {
    return Object.fromEntries(
      Object.entries(e.parameters).map(([key, val]) => [key, val[0]])
    );
  } else if (
    e.postData.type === "application/x-www-form-urlencoded" &&
    Array.isArray(e.parameters.payload) &&
    e.parameters.payload.length > 0
  ) {
    return JSON.parse(e.parameters.payload[0]);
  } else {
    return { token: "" };
  }
};

const isChannelEvent = (commands: Commands, payload: any) =>
  typeof payload?.event.channel !== "undefined" &&
  typeof commands?.channelEvent[payload.event.channel] === "function";

const isGlobalShortcut = (commands: Commands, payload: any) =>
  payload.type === "shortcut" &&
  typeof commands?.globalShortcut[payload.callback_id] === "function";

const isMessageShortcut = (commands: Commands, payload: any) =>
  payload.type === "message_action" &&
  typeof commands?.messageShortcut[payload.callback_id] === "function";

const isBlockActions = (commands: Commands, payload: any) =>
  payload.type === "block_actions" &&
  Array.isArray(payload.actions) &&
  payload.actions.length > 0 &&
  typeof payload.actions[0].action_id !== "undefined" &&
  typeof commands?.blockAction[payload.actions[0].action_id] === "function";

const isViewSubmission = (commands: Commands, payload: any) =>
  payload.type === "view_submission" &&
  typeof commands?.viewSubmission[payload.view.callback_id] === "function";

const isSlashCommand = (commands: Commands, payload: any) =>
  typeof payload.command !== "undefined" &&
  typeof commands?.slashCommand[payload.command] === "function";

export const createCommands =
  (command: Commands) =>
  (e: GoogleAppsScript.Events.DoPost): GoogleAppsScript.Content.TextOutput => {
    // 不正リクエスト
    if (typeof e.postData === "undefined") return ack("invalid request");
    const payload = parseRequest(e);
    if (payload.token !== process.env.SLACK_VERTIFICATION_TOKEN)
      return ack("invalid request");

    // チャレンジリクエスト
    if (typeof payload.challenge !== "undefined") return ack(payload.challenge);

    // チャンネルイベント
    if (isChannelEvent(command, payload))
      return command.channelEvent[payload.event.channel](payload);

    // グローバルショートカット
    if (isGlobalShortcut(command, payload))
      return command.globalShortcut[payload.callback_id](payload);

    // メッセージショートカット
    if (isMessageShortcut(command, payload))
      return command?.messageShortcut[payload.callback_id](payload);

    // ブロックアクション
    if (isBlockActions(command, payload))
      return command?.blockAction[payload.actions[0].action_id](payload);

    // モーダルの送信イベント
    if (isViewSubmission(command, payload))
      return command?.viewSubmission[payload.view.callback_id](payload);

    // スラッシュコマンド
    if (isSlashCommand(command, payload))
      return command?.slashCommand[payload.command](payload);

    // その他
    return ack("");
  };

こう使える。

global.doPost = createCommands({
  slashCommand: {
    "/hello": () =>
      ContentService.createTextOutput(
        JSON.stringify({
          text: "Hello World!!",
          response_type: "in_channel",
        })
      ).setMimeType(ContentService.MimeType.JSON),
  },
  globalShortcut: {
    question: (payload) => {
      slackApi(process.env.SLACK_BOT_TOKEN)("views.open")({
        view: JSON.stringify(questionStart()),
        trigger_id: payload.trigger_id,
        user_id: payload.user.id,
      });
      return ack("");
    },
  },
  blockAction: {
    question_bug: (payload) => {
      slackApi(process.env.SLACK_BOT_TOKEN)("views.push")({
        view: JSON.stringify(bugModal()),
        trigger_id: payload.trigger_id,
      });
      return ack("");
    },
    question_develop: (payload) => {
      slackApi(process.env.SLACK_BOT_TOKEN)("views.push")({
        view: JSON.stringify(devModal()),
        trigger_id: payload.trigger_id,
      });
      return ack("");
    },
  },
  viewSubmission: {
    "post-bug": (payload) => {
      const state = toInputValues(payload.view.state.values);
      slackApi(process.env.SLACK_BOT_TOKEN)("chat.postMessage")({
        channel: "dev",
        text: `<@${payload.user.id}> さんからバグの報告がありました!\n\n*URL*\n\n${state.url}\n\n*やったこと*\n\n${state.actions}\n\n*期待する動き*\n\n${state.expect}\n\n*実際の動き*\n\n ${state.actual}`,
      });
      return ack("");
    },
  },
});

全然型付けしてないけど、基本的な道具は揃った感じがする。

トークン検証

Basic Infromation からトークンを取得し、GAS へのリクエストに含まれるトークンフィールドがこのトークンか同値かどうかで検証する。
記載の通り、推奨された方法ではないが、GASではこの方法でしか検証できない

他の情報と同様に .env.* に入れてバンドル時にコードに埋め込んでしまうことにする。

各種イベントのペイロード

ログインするとコメントできます