GAS で Slack アプリ作る
以下を整理する
- Typescript + 3rd party npm module 利用で GAS を作るときの構成
- GAS で Slack アプリを作るときの構成
GAS は本番環境とテスト環境分ける
とりあえずこっから。
わりとどうでもいいけど、node の環境は nodenv で管理して、npm は corepackで管理するので、以下を実行した状態であること。
$ corepack enable npm
まずは 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
もろもろ入れる
$ 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
とりあえず簡単な実装とビルド設定を書いた。この状態でデプロイまでできるはず。
途中で Web エディタとか触ったから念の為頭からやり直したいところ
初回はデプロイを作成する必要があるから、npx clasp deploy
する。
以降は取得したデプロイIDを指定してデプロイする必要がある (URLがかわる)。
以降は取得したデプロイIDを指定してデプロイする必要がある (URLがかわる)。
ここで対応
新しいスクリプト作るときに以下を実行して 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 などの認証情報に関することを除けばいつもと同じなので。
リポジトリシークレットの設定もCLIで
リンター + フォーマッタ
$ 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
// 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.ts
に preset: 'ts-jest'
を足す
動作確認。
describe("test", () => {
test("test", () => {
expect(1 + 2).toBe(3)
})
})
$ npm test
> jest
PASS src/example.test.ts
test
✓ test (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
module.exports = {
"**/*.ts": (filename) => ["tsc -p tsconfig.json --noEmit", "eslint"],
};
GitHub Actions でのデプロイでの認証情報の扱いかたで参考になる。
.clasprc.json ファイルをそのままシークレットにすると、ログのマスキングに失敗するかもしれんからやめろとのことらしい。
Slack 側の設定する
ここにアクセス。
Create an App
From scratch
アプリ名とワークスペースを選ぶ
どういうアプリを作るか決める。
まずは slash command から
Create New Command
- コマンド名
- 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 プロジェクト自体の共有設定に注意する
とりあえず slash command に適当なもの返すようにする。
こうなって
こうなる
インタラクティブなやつ作る
アプリの設定画面から 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.* に入れてバンドル時にコードに埋め込んでしまうことにする。
各種イベントのペイロード
Block Actions
ショートカット (グローバルとメッセージ)
モーダル (サブミッション と クローズ)
スラッシュコマンド
チャンネルイベント