ブラウザで操作できるツールを作りたいけどWebのフロントエンド作るの面倒だな...とWeb系開発者にあるまじき事を考えつつ良いプラットフォームを探していたら、SlackのAPIが進化してツールフロントエンド化するのにうってつけの機能が増えているのを見つけました。
チャットツールのAPIといえば、「チャットで入力したテキストをボット側でがんばって解釈して実行する」というCUIのコマンド的な使い方しかできないイメージだったのですが、最近のSlackのAPIは「ショートカット(Shortcuts)」「モーダル(Modals)」「ブロックキット(Block Kit)」といった機能を使うことで、入力コンポーネントを使用したGUIでボットとやりとりすることができるようになっています。
これらを駆使すれば、入力フォームからデータを登録したり、編集フォームでデータを変更したり、削除ボタンでデータを削除したりするようなCRUD機能を持つGUIアプリを作ることもできそうです。
面白そうなので、使い方を調査しつつちょっとしたアプリを作ってみたいと思います。
APIを経由したSlackとのやりとりの基本
作成を始める前に、SlackのAPIの基本的な使い方をおさらいしておきましょう。
ここではSlackとやりとりするために作成するプログラムのことを「ボット」と呼びます。
SlackのAPIを利用する際は、まず開発テスト用のワークスペースを指定して「アプリ」を作成します、このアプリの設定でボットの権限を決めることで、その権限内でボットとSlackのワークスペース間でやりとりができるようにします。
Slackとボットの間でのやりとりには「ボットからSlackに対するアクション」「Slackからボットに対するアクション」の2つの方向があり、 それぞれ異なる方法で実装が必要となります。
ボットからSlackのワークスペースに対して何かアクションを起こしたい時はボットからアクション毎に用意された「Web API」を呼ぶことで実行します。例えばメッセージを投稿したい場合は、ボットからメッセージ投稿用のAPIを呼びます。
逆にSlackのワークスペースで発生したアクション(イベント)をボット側で受け取りたい場合は「Event API」経由でSlackからボットを呼んでもらいます。Slackはボットが受取可能なイベントが発生したら、HTTPリクエストとしてそのイベントの内容を送信してきます。そのためイベントを受け取るにはSlackからアクセス可能な場所にWebサーバを立てて、アクセス用のエンドポイントURLを作っておく必要があります。SlackのAPI管理画面でそのエンドポイントのURLを登録しておくと、イベント発生時にそのURLが呼ばれるようになります。
このやりとりの方法はSlackと双方向でインタラクティブなやりとりをする場合も変わりません。例えば「Slack側でボタンがクリックされたというイベント」を受け取って「ダイアログを表示する」という場合なら
- ボットWebサーバがEvent API経由でSlackからのボタンクリックイベントを受け取る
- ボットWebサーバ側からダイアログ表示用のWeb APIを呼ぶ
- Slack側でダイアログが表示される
という流れになります。
今回使用するSlack APIの機能について
今回はGUIなアプリを提供するために下記の機能を使います。
ショートカット (Shortcuts)
ショートカットというのは、利用者がボットを簡単に呼べるようにするためのものです。
Shortcuts: Allow users to initiate action in your app | Slack
「グローバルショートカット」と「メッセージショートカット」の2種類のショートカットが使用できます。
グローバルショートカットはワークスペースのメッセージ投稿欄にある稲妻マークのアイコンをクリックして表示されるショートカット一覧から、実行したいショートカットをクリックすることで実行できます。
メッセージショートカットはメッセージの操作を行うためのショートカットです。メッセージのその他メニューからショートカットを選択することで実行できます。例えばメッセージを編集するダイアログを開いたり、メッセージの内容を別の場所に送信するような機能を提供したい時に使用します。
モーダル (Modals)
モーダルはいわゆるダイアログです。入力フォームを表示して利用者に入力してほしい時に使用します。
Modals: focused spaces for user interaction | Slack
モーダルの内容は次に説明するブロックキットを使用して作成します。モーダルには入力項目とテキスト、そしてアクションイベントをもたせることができます。
ブロックキット (Block Kit)
ブロックキットは機能というよりは、SlackでUIを作成するための仕様のようなものです。仕様に合わせたJSONデータを用意することで、Slackがそれを解釈してUIを表示してくれます。
仕様自体は割とシンプルで覚えやすいものです。また、一からJSONを手書きしなくても、公式で用意されているブロックキットビルダー(Block Kit Builder) というツールを利用することでサクッと作成できるようになっています。
https://app.slack.com/block-kit-builder/
(使用するにはSlack API のサイトにログインしている必要があります。)
アプリを作成する
実際にこれらの機能を使ってちょっとしたデータ登録ツールを作ってみましょう。
一人でテレワークしているとついつい水分補給を忘れてしまいがちなので、夏に備えて1日に摂取した水分を記録する「水分摂取量記録アプリ」を作ることにします。
完成イメージはこんな感じ。
水分摂取量記録アプリの仕様
機能としては、
- ショートカットから「水分摂取記録」を選択すると登録用のモーダルが起動する。
- 登録用のモーダルで「飲んだもの」と「摂取量」を入力して保存すると、入力内容がデータベースに保存される。
- 保存後に「#general」チャンネルにメッセージとして入力内容とその日の摂取量が投稿される。
という内容とします。
ショートカットのイメージ
登録用モーダルのイメージ
登録後に表示されるメッセージのイメージ
準備するもの
今回のアプリはSlack側からボットへのリクエストが発生するので、Slackからアクセス可能なHTTPサーバを立てられる環境が必要です。AWS Lambda等のサーバレス環境でも良いですが、サンプルコードは普通のLinux環境を想定したものを記載します。
Slack APIを使用するために管理サイトでワークスペース用のアプリを準備して、ボット用のトークンを取得しておいてください。
Slack API: Applications | Slack
続いて管理サイトの「Interactivity & Shortcuts」の設定画面を開きます。
「Interactivity」を「On」に設定して、「Request URL」に上で準備したボットHTTPサーバのアクセス用URLを設定します。ここで指定したURLが、このアプリからボットへリクエストを送信する際に呼ばれるURLとなります。
画面の下の方に「Shortcuts」というブロックがあります。ここでショートカットの登録を行います。グローバルショートカットかメッセージショートカットかを選択後、ショートカットの情報を入力します。
「Name」がショートカットメニューに表示される名称となるので、「水分摂取記録」と入力します。(好きな名前を設定してください)
「Callback ID」が重要です。Slackからボットへのリクエストは上で設定した単一のRequest URLに送られるので、そのリクエストがどのような意図のものかの判別はCallback IDを見て行うことになります。ショートカット以外のリクエストもCallback ID付で送られてきます。
アプリに複数の機能を持たせたい場合もあるでしょうから、「機能名__処理名」みたいな感じにしておくと後で困りません。たとえば今回なら水分摂取(hydration)記録アプリの摂取記録(record_drink)のリクエストなので「hydration__record_drink」のような感じでCallback IDを設定しておきます。
とはいえ一度設定しても変更できますので、一通り作ってみてから整理しても問題ありません。
ショートカットを登録できたら、アプリをワークスペースにインストールしてSlack側の準備は完了です。
ボットを実装する
ボット側を実装する言語は何でも良いのですが、ここではGo言語での実装方法を紹介します。
完成版のソースはこちらで確認できます。
GitHub - pirosuke/go-slack-bot-hydration at v1.0
使用するライブラリ
HTTPサーバ機能にはEchoライブラリを使用します。
Echo - High performance, minimalist Go web framework
水分摂取データの記録先はPostgreSQLデータベースとします。 GoからPostgreSQLへのアクセスにはpgxライブラリのv4を使用します。
GitHub - jackc/pgx: PostgreSQL driver and toolkit for Go
ボットからSlackへのリクエストには標準のnet/httpライブラリを使用します。
http - The Go Programming Language
JSONの処理にはgo-jsonpointerライブラリを使用します。 SlackからリクエストされるJSONもBlock UI用のJSONも場合によって構成が異なるので、 パス指定で値の取得や更新ができるgo-jsonpointerが使いやすいです。
Slackからリクエストを受けとるベース処理を実装する
ショートカットであれ何であれSlackから送られるリクエストの処理方法は基本的に同じです。
- POSTでリクエストを受け取る
- JSON形式で送られるパラメータ「payload」を解析する
- payloadのtype値を取得し、リクエストタイプを判定する
- payloadのリクエストタイプに応じたパスからコールバックID (Callback ID)を取得する
- コールバックID毎に処理を実行する
- Slackにレスポンスを返す
1〜3はどのタイプのリクエストでも共通なので、先に実装しておきます。
go-slack-bot-hydration/server.go at v1.0 · pirosuke/go-slack-bot-hydration · GitHub
import ( "encoding/json" "net/http" echo "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" jsonpointer "github.com/mattn/go-jsonpointer" ) ... func main() { ... // Echoサーバを初期化 e := echo.New() ... // Slackに登録したURLでPOSTリクエストを受け取る。 e.POST("/", func(c echo.Context) error { return gateway(c ...) }) // Echoサーバを起動する。 e.Logger.Fatal(e.Start(":18081")) } func gateway(c echo.Context ...) error { // payloadパラメータで渡されるJSONテキストを取得 payloadJSON := c.FormValue("payload") // payloadをJSONオブジェクトに変換する。 var payload interface{} err := json.Unmarshal([]byte(payloadJSON), &payload) if err != nil { return c.String(http.StatusInternalServerError, "Error") } // リクエストタイプを取得する。 iRequestType, err := jsonpointer.Get(payload, "/type") if err != nil { return c.String(http.StatusInternalServerError, "Error") } requestType := iRequestType.(string) // リクエストタイプに対応したパスからコールバックIDを取得する。 var iCallbackID interface{} switch requestType { case "shortcut": iCallbackID, _ = jsonpointer.Get(payload, "/callback_id") case "view_submission": iCallbackID, _ = jsonpointer.Get(payload, "/view/callback_id") } callbackID := iCallbackID.(string) if len(callbackID) > 0 { switch callbackID { // ここにコールバック毎の処理を書く。 default: c.Echo().Logger.Warn("Unrecognized callbackID:", callbackID) } } return c.String(http.StatusForbidden, "Error") }
ショートカットがクリックされた時にモーダルを起動する
ベースができたので、個々の機能を実装していきます。
まずはショートカット「水分摂取記録」がクリックされたら水分摂取情報入力用のモーダルを起動する機能です。
Slack APIの管理サイトでショートカットのコールバックIDとして「hydration__record_drink」を設定済みなので、SlackからのリクエストのコールバックIDが「hydration__record_drink」の場合にモーダルを返す処理を書きます。
ショートカットクリックイベントを受け取ってレスポンスを返す
Slackからショートカットクリックイベントリクエストを受け取る処理と、モーダル起動をSlackにリクエストする処理は別になります。
基本的にSlackからリクエストを受け取ったら3秒以内にステータス200のレスポンスを返さないと、 リクエスト失敗と見なされますので、まずリクエストに対してレスポンスを返す処理を書きます。
func gateway(c echo.Context, appConfig config.Config, configsDirPath string) error { ... callbackID := iCallbackID.(string) if len(callbackID) > 0 { switch callbackID { // ショートカットイベントを処理する条件式を追加 case "hydration__record_drink": return HandleOpenHydrationForm(c, appConfig, configsDirPath, payload) default: c.Echo().Logger.Warn("Unrecognized callbackID:", callbackID) } } return c.String(http.StatusForbidden, "Error") } // ショートカットにレスポンスを返す。 func HandleOpenHydrationForm(c echo.Context, appConfig config.Config, configsDirPath string, payload interface{}) error { // 後ほどここにモーダル起動リクエスト処理を追加する。 // ステータス200でレスポンスを返す。 return c.String(http.StatusOK, "Ok") }
モーダル用のUI定義JSONを作成しておく
モーダル起動リクエスト処理の前に、表示するモーダルの内容をJSONファイルに保存しておきます。
ブロックキットビルダーで作成したものをファイルに保存すれば簡単に作成できます。
go-slack-bot-hydration/record_form.json at v1.0 · pirosuke/go-slack-bot-hydration · GitHub
{ "callback_id": "hydration__record_form", "title": { "type": "plain_text", "text": "水分摂取量記録" }, "submit": { "type": "plain_text", "text": "記録する" }, "blocks": [ { "type": "input", "block_id": "drink", "element": { "type": "plain_text_input", "action_id": "drink", "placeholder": { "type": "plain_text", "text": "何飲んだ?" } }, "label": { "type": "plain_text", "text": "飲み物の種類" } }, { "type": "input", "block_id": "amount", "element": { "type": "static_select", "action_id": "amount", "placeholder": { "type": "plain_text", "text": "選択してください", "emoji": true }, "options": [ { "text": { "type": "plain_text", "text": "100ml", "emoji": true }, "value": "100" }, { "text": { "type": "plain_text", "text": "200ml", "emoji": true }, "value": "200" }, ... ] }, "label": { "type": "plain_text", "text": "摂取量", "emoji": true } } ], "type": "modal" }
「callback_id」はこのフォームがSubmitされた時に入力内容とともSlackからボットに送信されるコールバックIDです。分かりやすい名前を設定しておきましょう。
「submit」はこのフォームをSubmitするボタンの設定です。このボタンをクリックするとモーダルへの入力内容がボットに送信されます。
入力項目は「飲み物の種類」と「摂取量」の2つです。入力されたデータを後で取得する際に各項目の「block_id」と「action_id」を使用しますので、「飲み物の種類」は「drink」、「摂取量」は「amount」をそれぞれ設定しておきます。
モーダル起動リクエストを作成する
モーダルはSlackのWeb API「view.open」にトリガーIDとモーダルのビュー定義を含むJSONを送信することで表示されます。
Using modals in Slack apps | Slack
{ "trigger_id": "トリガーID" "view": {ビュー定義} }
トリガーIDというのはモーダルを起動するトリガーとなるイベントです。今回はショートカットクリックがイベントとなります。
ショートカットクリック時にSlackから送信されるpayloadにトリガーIDが含まれるので、それをモーダル起動リクエストのJSONにセットします。 このトリガーIDは3秒で使用できなくなるので、 ショートカットクリックリクエストを受け取ると同時にモーダル起動リクエストを作成して送信する必要があります。
Slackへのリクエストは、コマンド毎のURLに認証用のトークンとJSONデータをPOSTする形で共通化されているので、汎用的なリクエストPOST用関数を作成しておきます。
go-slack-bot-hydration/slack.go at v1.0 · pirosuke/go-slack-bot-hydration · GitHub
func PostJSON(token string, command string, paramJSON string) ([]byte, error) { client := &http.Client{} req, _ := http.NewRequest("POST", "https://slack.com/api/"+command, strings.NewReader(paramJSON)) req.Header.Add("Content-type", "application/json") req.Header.Add("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() return ioutil.ReadAll(resp.Body) }
UI定義JSONファイルを読み込んでトリガーIDと組み合わせたJSONを作成し、「view.open」リクエストを実行する関数を作成します。
go-slack-bot-hydration/slack_repository.go at v1.0 · pirosuke/go-slack-bot-hydration · GitHub
func (repo *SlackRepository) OpenHydrationAddView(triggerID string) ([]byte, error) { var err error var resp []byte var requestParams, view interface{} // UI定義JSONを読み込む。 viewPath := filepath.Join(repo.ViewsDirPath, "record_form.json") if !file.FileExists(viewPath) { return resp, fmt.Errorf("View file does not exist: %s", viewPath) } // トリガーIDと結合したJSONを生成する。 requestJSON := `{"trigger_id": "", "view": {}}` err = json.Unmarshal([]byte(requestJSON), &requestParams) if err != nil { return resp, err } viewJSON, err := ioutil.ReadFile(viewPath) if err != nil { return resp, err } err = json.Unmarshal([]byte(viewJSON), &view) if err != nil { return resp, err } err = jsonpointer.Set(requestParams, "/view", view) if err != nil { return resp, err } err = jsonpointer.Set(requestParams, "/trigger_id", triggerID) if err != nil { return resp, err } requestParamsJSON, err := json.Marshal(requestParams) if err != nil { return resp, err } // リクエストを送信する。 resp, err = slack.PostJSON(repo.Token, "views.open", string(requestParamsJSON)) return resp, err }
この関数をSlackからショートカットクリックイベントリクエストが来た時に実行します。
レスポンスの3秒制限もありますので、別goroutineで非同期で実行されるようにしておきます。
// ショートカットにレスポンスを返す。 func HandleOpenHydrationForm(c echo.Context, appConfig config.Config, configsDirPath string, payload interface{}) error { // モーダル起動リクエスト処理を追加する。 go func() { slackRepo := &repositories.SlackRepository{ Token: appConfig.Slack.Token, ViewsDirPath: filepath.Join(configsDirPath, "views"), } triggerID, _ := jsonpointer.Get(payload, "/trigger_id") _, err := slackRepo.OpenHydrationAddView(triggerID.(string)) if err != nil { c.Echo().Logger.Error(err) } }() // ステータス200でレスポンスを返す。 return c.String(http.StatusOK, "Ok") }
ここまでで「Slack側でショートカットをクリックしたらモーダルを表示する」までの実装ができました。
モーダルがSubmitされた時にデータを登録してメッセージを投稿する
最後の工程として、モーダルにデータが入力されて「記録する」ボタンがクリックされたら入力内容をデータベースに登録して、登録結果をメッセージとしてSlackに投稿する機能を実装します。
データベースの準備
あらかじめPostgreSQLに下記のようなデータベースとテーブルが存在するものとします。
go-slack-bot-hydration/configs/sql at v1.0 · pirosuke/go-slack-bot-hydration · GitHub
create database slack_bot encoding = UTF8; create table hydrations( id serial ,username varchar(255) not null ,drink varchar(255) not null ,amount int not null ,modified timestamp not null ,primary key (id) ) ;
モデルとデータベース操作用構造体を作成する
データベースにアクセスするための構造体とモデルとなる構造体を作成しておきます。
go-slack-bot-hydration/hydration.go at v1.0 · pirosuke/go-slack-bot-hydration · GitHub
type Hydration struct { // ユーザー名 Username string // 飲み物の種類 Drink string // 摂取量 Amount int64 // 更新日 Modified time.Time }
go-slack-bot-hydration/hydration_pg_repository.go at v1.0 · pirosuke/go-slack-bot-hydration · GitHub
type HydrationPgRepository struct { conn *pgx.Conn } // 接続用メソッド func (repo *HydrationPgRepository) Connect(config database.DbConfig) error { var err error dbURL := "postgres://" + config.Connection.User + ":" + config.Connection.Password + "@" + config.Connection.Host + ":" + strconv.FormatInt(config.Connection.Port, 10) + "/" + config.Connection.Database repo.conn, err = pgx.Connect(context.Background(), dbURL) return err } // 切断用メソッド func (repo *HydrationPgRepository) Close() { repo.conn.Close(context.Background()) } // データ登録用メソッド func (repo *HydrationPgRepository) Add(hydration models.Hydration) error { _, err := repo.conn.Exec(context.Background(), "insert into hydrations(username, drink, amount, modified) values($1, $2, $3, $4)", hydration.Username, hydration.Drink, hydration.Amount, hydration.Modified, ) return err } // その日の摂取総量取得用メソッド func (repo *HydrationPgRepository) FetchDailyAmount(userName string) int64 { var totalAmount int64 err := repo.conn.QueryRow(context.Background(), "select sum(amount) from hydrations where username = $1 and modified::date = now()::date", userName).Scan(&totalAmount) if err != nil { fmt.Println(err) } return totalAmount }
Slackに登録内容を投稿する処理を作成する
水分摂取データを保存後はSlackに保存した内容とその日の総摂取量をメッセージとして投稿します。
メッセージの投稿には「chat.postMessage」APIを使用します。
chat.postMessage method | Slack
モーダルと同じくBlock UIを使用するので、UI定義JSONファイルを読み込む形にしても良かったのですが、 内容的に小さいので構造体でJSONを組み立てる方式で作成してみます。
type ( //FieldsSectionBlock describes section block for slack message. FieldsSectionBlock struct { Type string `json:"type"` Fields []ContentBlock `json:"fields"` } //TextSectionBlock describes section block for slack message. TextSectionBlock struct { Type string `json:"type"` Text ContentBlock `json:"text"` } //ContentBlock describes content block for slack message. ContentBlock struct { Type string `json:"type"` Text string `json:"text"` } )
func (repo *SlackRepository) PostHydrationAddResult(userName string, channel string, hydration models.Hydration, dailyAmount int64) ([]byte, error) { var err error var resp []byte titleSection := slack.TextSectionBlock{ Type: "section", Text: slack.ContentBlock{ Type: "mrkdwn", Text: "@" + userName + " が飲み物を飲みました\n本日の合計量は " + strconv.FormatInt(dailyAmount, 10) + "ml です", }, } contentList := []slack.ContentBlock{ { Type: "mrkdwn", Text: "*飲んだもの:*\n" + hydration.Drink, }, { Type: "mrkdwn", Text: "*摂取量:*\n" + strconv.FormatInt(hydration.Amount, 10) + "ml", }, } contentSection := slack.FieldsSectionBlock{ Type: "section", Fields: contentList, } var requestParams interface{} requestJSON := `{"channel": "", "blocks": []}` err = json.Unmarshal([]byte(requestJSON), &requestParams) if err != nil { return resp, err } jsonpointer.Set(requestParams, "/channel", channel) jsonpointer.Set(requestParams, "/blocks", []interface{}{ titleSection, contentSection, }) // 作成した構造体をJSON文字列化する。 requestParamsJSON, err := json.Marshal(requestParams) if err != nil { return resp, err } // 作成したJSON文字列パラメータとしてSlackメッセージ投稿APIに渡す。 resp, err = slack.PostJSON(repo.Token, "chat.postMessage", string(requestParamsJSON)) return resp, err }
モーダルのSubmitイベントを受け取る処理を実装する
最後に、モーダルからのSubmitイベント受信時に入力内容をデータベースに登録してメッセージを投稿するように作成した処理を組み合わせます。
callbackID := iCallbackID.(string) if len(callbackID) > 0 { switch callbackID { case "hydration__record_drink": return HandleOpenHydrationForm(c, appConfig, configsDirPath, payload) // モーダルSubmit処理用のハンドラを追加する。 case "hydration__record_form": return HandleHydrationFormSubmission(c, appConfig, configsDirPath, payload) default: c.Echo().Logger.Warn("Unrecognized callbackID:", callbackID) } }
func HandleHydrationFormSubmission(c echo.Context, appConfig config.Config, configsDirPath string, payload interface{}) error { // payloadから入力内容を取得する。 iDrink, _ := jsonpointer.Get(payload, "/view/state/values/drink/drink/value") iAmount, _ := jsonpointer.Get(payload, "/view/state/values/amount/amount/selected_option/value") iUserName, _ := jsonpointer.Get(payload, "/user/username") drink, _ := iDrink.(string) amount, _ := iAmount.(string) userName, _ := iUserName.(string) intAmount, _ := strconv.ParseInt(amount, 10, 64) // データベースへの登録とメッセージの投稿は別goroutineで非同期実行する。 go func() { // 入力内容を構造体にセットする。 hydration := models.Hydration{ Username: userName, Drink: drink, Amount: intAmount, Modified: time.Now(), } // 入力内容をデータベースに登録する。 err := repo.Add(hydration) if err != nil { c.Echo().Logger.Error(err) return } // 入力内容登録後のその日の水分総摂取量を取得する。 dailyAmount := repo.FetchDailyAmount(userName) slackRepo := &repositories.SlackRepository{ Token: appConfig.Slack.Token, ViewsDirPath: filepath.Join(configsDirPath, "views"), } // 登録内容と水分総摂取量をメッセージにしてSlackに投稿する。 _, err = slackRepo.PostHydrationAddResult(userName, "#general", hydration, dailyAmount) if err != nil { c.Echo().Logger.Error(err) return } }() return c.String(http.StatusOK, "") }
これで主な機能の実装は終了です。
おわりに
長くなりましたがSlack APIをツールのフロントエンドとして使用するための機能の紹介と、 実際に使用したサンプルアプリの作成を行ないました。
この内容をベースにボットの処理を変更すればSlackを自分用のタスクランナーとして使ったり、 ノンプログラマ向けのツールを作ったりできそうです。
今回扱うことができなかったデータの「変更」と「削除」については下記記事で紹介していますので、よろしければこちらも見てみてください。