Open67

GAS で Slack アプリ作る

sterashima78sterashima78

以下を整理する

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

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

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

開発用と本番用それぞれスクリプト作る。
.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

sterashima78sterashima78

もろもろ入れる

$ 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
sterashima78sterashima78

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

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

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

sterashima78sterashima78

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

sterashima78sterashima78

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

ここで対応
https://github.com/sterashima78/slack-app-gas-ts/tree/f37699d7fe96efc5b7fca7b8fb91f95a20984a1d

sterashima78sterashima78

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

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

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

sterashima78sterashima78

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

sterashima78sterashima78

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

sterashima78sterashima78

リンター + フォーマッタ

$ 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": {
    }
};

sterashima78sterashima78

npm scripts 足しておく

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

ユニットテスト

$ 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' を足す

sterashima78sterashima78

動作確認。

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.
sterashima78sterashima78

lint-staged

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

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

sterashima78sterashima78

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

OAuth & Permissionsをひらく

sterashima78sterashima78

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

sterashima78sterashima78

Bot Token 周りのやつ

sterashima78sterashima78

方針は

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

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

sterashima78sterashima78

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

sterashima78sterashima78

方針を変える

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

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

sterashima78sterashima78

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

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

sterashima78sterashima78

以下で、

{
	"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"
			}
		}
	]
}

以下のような表示になる

sterashima78sterashima78

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

sterashima78sterashima78

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

sterashima78sterashima78

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

sterashima78sterashima78

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)
  );

sterashima78sterashima78

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

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("");
  };

sterashima78sterashima78

こう使える。

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("");
    },
  },
});

sterashima78sterashima78

トークン検証

sterashima78sterashima78

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

sterashima78sterashima78

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