GxPの@h-uminoue-gxpです。
この記事はグロースエクスパートナーズ Advent Calendar 2022の19日目です。
概要
社内用など、何かの目的でちょっとしたslackアプリをサクッと作りたい時、GAS(Google Apps Script)が手軽に使えて便利なのですが(GASをおすすめしてくださった社内のSさんいつもお世話になってます)、標準エディタが使いにくかったりnpmのライブラリが使えないなど、不便に思うこともそれなりにありました。
どうにかならないかなと思っていましたが、clasp(Google謹製のGAS用CLIツール)を用いつつその他諸々設定することで、結果的にはそれなりに思い描いていたものに近い環境が作れました。
こんなことがしたかった本当の理由
ちょっとしたslackアプリを手軽にサクっとが目的なら、そこまで気合入れて環境構築しなくても無理なくやれる範囲でやれば…と思わなくもないですが、そもそもこんなことがしたかったのは、モーダルやメッセージのUIをjson書いて構築するのが本当に苦手なので、なんとかしてjsx-slack(UIをJSXで書かせてくれるステキなライブラリ)を使って書けないかなと思ったからです。
という訳で以下では、利用するnpmライブラリとしてjsx-slackを例に書いてます。
何ができるの(GASでslackアプリ作ったことない方向け)
slack APIを用いたワークスペースの操作は当然として、組み込みメソッドのdoPost
でPOSTメソッドをリッスンできるので、スラッシュコマンドなどを用いてslackから任意にスクリプトを発火させたり、Events APIを用いて特定のイベントに合わせて発火させることも可能。また、Interactivityか否かをスクリプト側でpayloadの有無を見ることで判別できるので、モーダルなどを使用したインタラクティブなアプリを作ることも可能です。
欠点として、リクエストヘッダーにアクセスできないため、slackからのリクエストを検証する際、推奨されているX-Slack-Signature
を検証する方法ではなく、非推奨になっているVerification Token
を検証する方法しか使えません(verifying requests from slack)。外部に公開するようなアプリでインタラクティブな要素を含むものはGASで実装しない方が良さそうです。
作例
簡単な作例として、/test
とスラッシュコマンドを送信すると、決まったメッセージを返すだけのものすごく単純なbotを作ります。
あまりにも簡単すぎる作例ですみませんが、一応こんなのでも今回やりたかったことの内容は網羅できるのでご容赦ください。
claspでGASを書く下準備
この手順についてはすでに沢山の方々が書かれてますが一応こちらでも簡単に。
既にclaspでGAS書いてる方は読み飛ばしてください。
まずは利用中のGoogleアカウントのGASの設定を有効化しておきます。
https://script.google.com/home/usersettings
からGoogle Apps Script APIをオンにします。
プロジェクトフォルダに移動し、claspのインストールを済ませます。
後々jsx-slack使うのとTypeScriptで書きたいのでinitもしておきます。
claspをグローバルに入れるのが嫌ならローカルに入れても構いませんが、その場合以降のclaspコマンドはnpxで叩いてください。
$ cd gas_slack_sample
$ yarn init
$ yarn global add @google/clasp
TypeScriptとGASの型定義ファイル。
$ yarn add -D typescript @types/google-apps-script
claspでログインします。
おそらくブラウザでログイン許可を求められるはずですので許可してください。
$ clasp login
正常にログインできればターミナルに以下のように表示されるはず。
Warning: You seem to already be logged in *globally*. You have a ~/.clasprc.json
Logging in globally…
🔑 Authorize clasp by visiting this url:
https://accounts.google.com/o/oauth2/v2/auth? ...
Authorization successful.
Default credentials saved to: /*****/.clasprc.json.
claspのプロジェクトを作ります。
プロジェクト名はとりあえずプロジェクトフォルダと同じ名前にしましたが別名でも問題ないようです。
$ clasp create gas_slack_sample
GASをどう利用するか訊かれますので目的に合わせて選択。
本記事ではスプレッドシートとの連携まではやりませんが、slackアプリが目的ならスプレッドシートとGASを組み合わせるsheetsがオススメです(ログ出力、簡易DB、重い処理をスプレッドシートの書き換えをトリガーとするメソッドに分離してslackの3秒ルールを回避する、など色々とスプレッドシートが使えます)。
? Create which script? (Use arrow keys)
standalone
docs
> sheets
slides
forms
webapp
api
プロジェクトの作成が完了し
? Create which script? sheets
Created new Google Sheet: https://drive.google.com/open?id=...
Created new Google Sheets Add-on script: https://script.google.com/d/*****/edit
Warning: files in subfolder are not accounted for unless you set a '/*****/gas_slack_sample/.claspignore' file.
Cloned 1 file.
└─ /*****/gas_slack_sample/appsscript.json
ローカルにはclasp.jsonとappsscript.jsonが作成されます。
$ ls -l
total 0
-rwxrwxrwx 1 *** *** 0 Nov 24 11:37 app.ts
-rwxrwxrwx 1 *** *** 115 Nov 24 03:07 appsscript.json
-rwxrwxrwx 1 *** *** 230 Nov 24 01:23 package.json
clasp openコマンドでclasp.jsonに書かれているスクリプトIDを参照して、ブラウザで該当スクリプトIDのGASエディタが開きます。確認してみます。
$ clasp open
Opening script: https://script.google.com/d/*****/edit
GAS、スプレッドシートともに作成されています。
ついでにGASのタイムゾーンを日本に変更しておきましょう。
この設定はappsscript.jsonに保存されるのですが、今ブラウザから変更したのでローカルのappsscript.jsonに反映されていません。同期させるにはclasp pullを実行します。
$ clasp pull
Warning: files in subfolder are not accounted for unless you set a '*****/gas_slack_sample/.claspignore' file.
Cloned 1 file.
└─ *****/gas_slack_sample/appsscript.json
手元のエディタでappsscript.jsonを確認してみると、タイムゾーンの書き換えが反映されています。
{
"timeZone": "Asia/Tokyo",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}
まだプロジェクト作りたてで何のスクリプトもないので、Hello World的に試しに何か作ってみます。
(以前はプロジェクト作ると空のスクリプトファイルが1つ自動的に作成されたらしいのですが、仕様変わったのでしょうか)
プロジェクトフォルダ直下に以下の内容でapp.tsを作成しました。
export const doGet = () => {
return ContentService.createTextOutput("Hello GAS and Clasp.");
}
一旦これでデプロイして動作確認します。
まずclasp pushでソースをGASにpushします。
$ clasp push
└─ ***/gas_slack_sample/app.ts
└─ ***/gas_slack_sample/appsscript.json
Pushed 2 files.
GASエディタから確認してみます。
push時に自動的にtsからgsファイル(GASのスクリプトファイル)にclaspが変換してくれます。
ただし、今回やりたかったことをやろうとすると、claspのトランスパイルだけでは不十分になります。詳細は後述。
npmライブラリ一切使わず1つのソースで完結する簡単なスクリプトを作るならclaspに任せっきりで十分そうです。この辺、通常用途ならすごく楽ですね。
デプロイは初回のみブラウザ上から行います。
GASエディタから、デプロイ→新しいデプロイ→種類の選択→ウェブアプリ
「アクセスできるユーザ」は用途に合わせて設定してデプロイ
デプロイされたので発行されたURLにアクセスして動作確認。
良さそうですね。
ブラウザから変更を加えたのでclasp pullします。
$ clasp pull
Warning: files in subfolder are not accounted for unless you set a 'E:\git\gas_slack_sample\.claspignore' file.
Cloned 2 files.
└─ ***/gas_slack_sample/appsscript.json
└─ ***/gas_slack_sample/app.js
トランスパイル後のファイル(今回はapp.js)は消しておきます(消しておかないとclaspがapp.ts→app.jsにトランスパイルする際、app.jsが既に存在するって事でエラーが出ます)。
次回以降のデプロイはローカルでclaspから行います。
app.tsを以下のように書き換えました。
export const doGet = () => {
return ContentService.createTextOutput("このアプリはグロースエクスパートナーズ Advent Calendar 2022 19日目のサンプルです。");
}
これをデプロイしてみます。
clasp deploymentsで先ほどのデプロイIDが取得できます。HEADではない方の最新を使用します。この場合は@1。
$ clasp deployments
2 Deployments.
- AKfycbyTt... @HEAD
- AKfycbxIX... @1
デプロイはclasp deployです。
-iオプションで先ほどのデプロイIDを指定してデプロイすることでアプリを上書きできます。
$ clasp deploy -i AKfycbxIX...
Created version 2.
- AKfycbxIX... @2.
同じデプロイIDでversion 2が作成されました。
※上書きせず単にclasp deployすると先ほどのURLでversion 1が残ったままversion 2用のURLが新規に作られてしまい、面倒なことになります。
先ほどのURLにアクセスしてデプロイされたか確認してみます。
良さそうですね。
slackアプリの準備
slackアプリの準備もしておきましょう。
新しくslackアプリを作って、/testとスラッシュコマンドを打つとGASのスクリプトが発火するようにしておきます。
slackアカウントにログインした状態でhttps://api.slack.com/appsにアクセス。Create New App → From scratch → アプリ名とワークスペースを入力してCreate App。
アプリが作成できたら画面左のFeaturesにあるSlash Commands → Create New Commands → Commandに/test、Request URLにさっきGASで作ったアプリのURLを入力してsave。
このアプリをワークスペースにインストールします。
同じく画面左のFeaturesにあるOAuth & PermissionsからOAuth Tokens for Your WorkspaceのInstall to Workspace → 許可する。
生成されたBot User OAuth Tokenはこの後使うので控えておきます。
同じくSettings → Basic Information → Verification Tokenも控えておきます。
試しに何かメッセージをGASからslackに投稿させてみます。動作確認も兼ねて。
Features → OAuth & Permissions → Scopes → Add an OAuth Scope → chat:writeを追加 → slackアプリを再インストールするよう警告が出るので再インストール。
GASからslackAPIを叩いてメッセージを投稿するには先ほどのBot User OAuth Tokenが必要です。
また、slackからのリクエストなのかどうかをGASのスクリプト内で検証するのにVerification Tokenを使用します。
それらをスクリプト内にハードコーディングするのは嫌なので、GASのスクリプトプロパティに保存し、そこからスクリプト側で読み込むようにします。
先ほどGASのタイムゾーンを変更したところの下にスクリプトプロパティを設定できるところがありますので、以下のように追加して保存します。
投稿先のチャンネルは可変にしたいことも多々あると思いますが、本記事は作例ですのでとりあえず投稿先は固定にしてしまおうかと思います。そのため、投稿先のチャンネルIDも一緒に登録しておきました。
保存したプロパティはGASのスクリプトのソースからPropertiesService
を経由して取り出せます。
次はGASのスクリプトからメッセージを投稿する部分を書きます。
app.tsを以下のように追記しました。
import DoPost = GoogleAppsScript.Events.DoPost;
import URLFetchRequestOptions = GoogleAppsScript.URL_Fetch.URLFetchRequestOptions;
const BOT_TOKEN = PropertiesService.getScriptProperties().getProperty("BOT_TOKEN");
const VERIFY_TOKEN = PropertiesService.getScriptProperties().getProperty("VERIFY_TOKEN");
const CHANNEL_ID = PropertiesService.getScriptProperties().getProperty("CHANNEL_ID");
export const doGet = () => {
return ContentService.createTextOutput("このアプリはグロースエクスパートナーズ Advent Calendar 2022 19日目のサンプルです。");
}
export const doPost = (req: DoPost) => {
if (req.parameter.token === VERIFY_TOKEN) {
slackPostMessage("このメッセージはグロースエクスパートナーズ Advent Calendar 2022 19日目のサンプルアプリのデモです。");
}
return ContentService.createTextOutput();
}
export const slackPostMessage = (value: string) => {
const postUrl = "https://slack.com/api/chat.postMessage";
const postData = {
token: BOT_TOKEN,
channel: CHANNEL_ID,
text: value
};
const options: URLFetchRequestOptions = {
method: "post",
payload: postData
};
UrlFetchApp.fetch(postUrl, options);
}
これをデプロイします。投稿先のslackチャンネルにも忘れずこのアプリを追加しておくこと。
投稿先チャンネルで/testとスラッシュコマンドを打ってそのまま書き込みます。
すんなり成功すればいいですが、もしかしたら以下のようなエラーが発生するかもしれません(筆者環境では発生しました)。
この場合、doGetメソッドを実装した時と同じようにGASのアプリのURLに直接アクセスして、doGetの動きに変わりがないか見てみます。
「その操作を実行するには承認が必要です」と返ってきた場合、スクリプトの先頭でプロパティを読んでいるところで権限が足りずエラーが起きています。
その場合はGASのエディタ上からdoGetメソッドを直接実行すると、権限を付与するかブラウザが訊いてきますので、画面の指示に従って権限を与えてください。
改めてslackから /test
良いですね。これで一通りの準備ができました。
npmライブラリ(jsx-slack)を使えるようにする
ようやく本題です。
GASのエディタ上から、トランスパイル後のスクリプトファイルであるapp.gsを見てみると、importがコメントアウトされているのが見えます。
調べてみるとどうやらclaspはimport/exportに対応しておらず、これらをコメントアウトしてしまうようです。
先ほど書いたimportは単に型の読み込みですから良いですが、これからjsx-slack使ってメッセージやモーダルをtsxに書いてそれをまたapp.tsにimportして…とやりたいのに、それでは困ります。
という訳でbabelとwebpackを使って自前でビルドします。
$ yarn add -D @babel/preset-react @babel/core @babel/preset-typescript babel-loader
$ yarn add -D webpack webpack-cli
@babel/preset-typescriptはclaspが使えないので当然として、@babel/preset-reactはjsx-slackが使うので入れてます。jsx-slack使わないなら不要です。逆に使いたいライブラリに必要なものがあるなら追加で入れてください。
yarn add -D gas-webpack-plugin
doPostなどGASが直接実行するメソッドをトップレベルに配置させるために必要です。
yarn add jsx-slack
忘れちゃいけませんね。
ここからwebpackなどの各種設定ファイルを書いていきますが、プロジェクト直下が混んでくるのでソースファイルだけでも今のうちに移動させます。
srcフォルダを作ってそこにapp.tsを移動させました。
gas_slack_sample
├── src
│ ├── app.ts
│ └── index.ts
babelの設定を書きます。jsx-slackが指定しているプリセットとtsのトランスパイルのためのプリセットを用意しました。
module.exports = (api) => ({
presets: [
[
"@babel/preset-typescript"
],
[
'@babel/preset-react',
{
runtime: 'automatic',
importSource: 'jsx-slack',
development: api.env('development'),
},
],
],
})
webpackの設定を書きます。babelの使用、gas-webpack-pluginの使用を設定しています。
const path = require("path");
const GasPlugin = require("gas-webpack-plugin");
module.exports = {
mode: "development",
devtool: false,
context: __dirname,
entry: "./src/index.ts",
output: {
path: path.join(__dirname, "dist"),
filename: "index.js",
},
resolve: {
extensions: [".ts", ".js", ".tsx"],
},
module: {
rules: [
{
test: /\.[tj]sx?$/,
exclude: /node_modules/,
loader: "babel-loader",
},
],
},
plugins: [new GasPlugin()],
};
起点となるindex.tsをsrcに作成し、以下のように書きます。
import {doGet, doPost} from "./app";
declare const global: {
[method: string]: unknown;
};
global.doGet = doGet;
global.doPost = doPost;
先ほど少し触れましたが、doGetやdoPostなど、GASが直接実行するメソッドは、トップレベルに置いておく必要があります。globalオブジェクトにそういったメソッドを登録しておくと、gas-webpack-pluginが良い具合に配置してくれるようになります。
app.tsの先頭のimport2つは消します(残ってるとビルド後実行した時、GoogleAppsScript以下がインポートできずエラーになります)。それらの使用箇所については仕方がないのでネームスペースから書くようにします。
import DoPost = GoogleAppsScript.Events.DoPost; // ← 消す
import URLFetchRequestOptions = GoogleAppsScript.URL_Fetch.URLFetchRequestOptions; // ← 消す
// ...
export const doPost = (req: DoPost) => { // ← GoogleAppsScript.Events.DoPostに変更
// ...
const options: URLFetchRequestOptions = { // ← GoogleAppsScript.URL_Fetch.URLFetchRequestOptionsに変更
メッセージ用のtsxを書きます。
import {Blocks, Divider, JSXSlack, Section} from "jsx-slack";
export default () => {
return JSXSlack(
<Blocks children={null}>
<Section children={null}>
このメッセージはグロースエクスパートナーズ Advent Calendar 2022 19日目のサンプルアプリのデモです。
</Section>
<Divider/>
<Section children={null}>
<a href={"https://qiita.com/advent-calendar/2022/gxp"}>他の日の記事はこちら。</a>
</Section>
</Blocks>
)
}
app.tsで上のtsxを使うように書き換えました。
最終的なapp.tsは以下のようになります。
import message from "./message";
const BOT_TOKEN = PropertiesService.getScriptProperties().getProperty("BOT_TOKEN");
const VERIFY_TOKEN = PropertiesService.getScriptProperties().getProperty("VERIFY_TOKEN");
const CHANNEL_ID = PropertiesService.getScriptProperties().getProperty("CHANNEL_ID");
export const doGet = () => {
return ContentService.createTextOutput("このアプリはグロースエクスパートナーズ Advent Calendar 2022 19日目のサンプルです。");
}
export const doPost = (req: GoogleAppsScript.Events.DoPost) => {
if (req.parameter.token === VERIFY_TOKEN) {
slackPostMessage();
}
return ContentService.createTextOutput();
}
export const slackPostMessage = () => {
const postUrl = "https://slack.com/api/chat.postMessage";
const postData = {
token: BOT_TOKEN,
channel: CHANNEL_ID,
blocks: JSON.stringify(message())
};
const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
method: "post",
payload: postData
};
UrlFetchApp.fetch(postUrl, options);
}
さて、今後はwebpackからビルドするようになるわけですが、webpackの後にclasp pushと毎回2個コマンド叩くのがメンドくさくなったのでyarn deployにまとめました。
{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack",
"deploy": "yarn build && clasp push"
},
}
また、ビルド後のjsファイルだけpushするようにしたいので、.clasp.jsonのrootDirを./distに変更します。
{"scriptId":"***","rootDir":"./dist","parentId":["***"]}
ただし、appsscript.jsonファイルだけはpush対象から除外できません。よって、あらかじめdistフォルダを作成し、そこにappsscript.jsonを移動させます。
ここまでで以下のようになっているはずです。
gas_slack_sample
├── babel.config.js
├── .clasp.json
├── dist
│ └── appsscript.json
├── node_modules
├── package-lock.json
├── package.json
├── src
│ ├── app.ts
│ ├── index.ts
│ └── message.tsx
├── webpack.config.js
└── yarn.lock
ビルドしてみます。ここまで各設定がうまくいっていれば通るはずです。
$ yarn deploy
yarn run v1.22.19
$ yarn build && clasp push
$ webpack
asset index.js 130 KiB [emitted] (name: main)
orphan modules 105 KiB [orphan] 22 modules
runtime modules 891 bytes 4 modules
modules by path ./node_modules/jsx-slack/module/ 62.7 KiB
modules by path ./node_modules/jsx-slack/module/src/ 35.8 KiB 38 modules
modules by path ./node_modules/jsx-slack/module/node_modules/ 3.52 KiB
modules by path ./node_modules/jsx-slack/module/node_modules/unist-util-visit-parents/*.mjs 972 bytes 2 modules
+ 5 modules
modules by path ./node_modules/jsx-slack/module/vendor/*.mjs 23.4 KiB
./node_modules/jsx-slack/module/vendor/hastUtilToMdast.ts.mjs 22.8 KiB [built] [code generated]
./node_modules/jsx-slack/module/vendor/chunk-TWLJ45QX-093d6be0.mjs 542 bytes [built] [code generated]
modules by path ./src/ 2.37 KiB
./src/index.ts 84 bytes [built] [code generated]
./src/app.ts 953 bytes [built] [code generated]
./src/message.tsx 1.36 KiB [built] [code generated]
webpack 5.75.0 compiled successfully in 2341 ms
└─ dist/appsscript.json
└─ dist/index.js
Pushed 2 files.
Done in 19.66s.
デプロイしてslackから/testを打って動作確認してみます。
良いですね!
あとがき
UI構築Jsonで書くの嫌だというところから始まった今回の話ですが、結果的にはやりたかったことができるようになりました。
残念ながらnodeに依存するようなライブラリはGASにnodeがないので使えません(Github連携でoctokit使いたかった)。
GASの外部ライブラリを使って書いてる場合などもローカルでビルドしなきゃならない関係上あまり向かないかなと思います(書けないことはないですが)。
筆者の用途としてはとりあえず満足行く結果になったので今後とも活用しようと思います。