ymmooot

管理画面の代わりに API の GUI を Slack に構築する

概要

自社のシステムに記事を入稿する際、今まではデータをもらって自分がそれを手元のスクリプトで SQL に変換して DB に入れる作業をしていた。
これだと自分の手間も増えるし、毎日記事を更新するのが大変。自分の手からこの業務を離すために、とりあえず GraphQL の API に記事入稿 Mutation を生やしたのだが、さすがに「この API から入稿してください」とは言えないので、クライアントを用意する必要があった。
なので、Slack から入稿させてしまうことにした。

Slack App の構築

最初は Slack ワークフローでフォームを作って、送信時に Slack App でそれを捌くようにしようかと思ったが、どうせ Slack App を作るならフォーム自体も Slack App で開いたほうができることが多いので、すべて Slack App でやることにした。

下準備

今回は TypeScript と Bolt で作る。Bolt は Slack が提供する Slack App 用フレームワークである。(Bolt 入門ガイド)
こういった SDK で作るときに型のサポートを受けられない言語で開発するのはあまりおすすめしない。楽したいから Slack App を作っているのに、SDK の利用にコストがかかると辛い。型情報はその辛さをかなり軽減してくれる。

ということで諸々インスコ。

$ npm install @slack/bolt dotenv
$ npm install -D typescript jest ts-node-dev ts-jest @types/jest @types/dotenv prettier
  • @slack/bolt: Slack App フレームワーク。
  • dotenv: 環境変数を .env から読み取る。別になくても良い。
  • typescript: TypeScript。
  • jest: テストフレームワーク。
  • ts-jest: TypeScript を Jest でテストできるように。
  • ts-node-dev: TypeScript 実行環境。開発時のホットリロード用。
  • prettier: コードフォーマッター。
  • @types/jest: jest の型情報。
  • @types/dotenv: dotenv の型情報。

あとは tsconfig.json jest.config.js とかをつくる。
とりあえず、app を作成。

index.tsimport { App, ExpressReceiver } from '@slack/bolt'
import { env } from './lib/env'

const receiver = new ExpressReceiver({
  signingSecret: env.SLACK_SIGNING_SECRET,
})

// health check
receiver.app.get('/healthz', (_, res) => {
  res.status(200).send('healthy')
})

const app = new App({
  token: env.SLACK_BOT_TOKEN,
  receiver,
})

./lib/env は環境変数を読み取ってチェックして export するだけのファイル。process.env に触るのを一箇所にしたくて別ファイルに分けてる。Bolt は /slack/events をエンドポイントとして POST しか受け付けないので適当にヘルスチェック用のエンドポイントを用意すると良い。

ここからは以下のステップで構築する。

  1. フォームを開くショートカットを作成
  2. フォームが送信されたらプレビューと確認ボタンを返す
  3. 確認ボタンを押されたら API を叩く
  4. 問題なく API を叩けたら結果を Slack に返す

1. フォームを開くショートカットを作成

article-form-shortcut.tsimport type { Middleware, SlackShortcutMiddlewareArgs, GlobalShortcut } from '@slack/bolt'
import { createInputTextBlock, createPlainTextOptions, createInputRadioBlock } from './lib/blocks'
import { modalCallbackID } from './view-callback'

export const articleFormShortcut: Middleware<SlackShortcutMiddlewareArgs<GlobalShortcut>> = async ({ ack, body, client }) => {
  await ack()
  await client.views.open({
    trigger_id: body.trigger_id,
    view: {
      type: 'modal',
      callback_id: modalCallbackID,
      title: {
        type: 'plain_text',
        text: 'New Article Form',
      },
      blocks: [
        createInputRadioBlock('country', 'Country', [
          createPlainTextOptions('id', 'Indonesia'),
          createPlainTextOptions('th', 'Thailand'),
          createPlainTextOptions('sg', 'Singapore'),
        ]),
        createInputTextBlock('title', 'Title', false),
        createInputTextBlock('description', 'Description', false),
        createInputTextBlock('category_id', 'Category ID (number)', false),        
        createInputTextBlock('body', 'Body (Markdown)', true),
      ],
      submit: {
        type: 'plain_text',
        text: 'Submit',
      },
    },
  })
}

ack() は Slack への応答。とりあえず返す。
client.views.open() で slack のモーダルが開く。ここに Block と呼ばれる画面要素を用いてフォームを作ることができる。Block Kit Builder を使うと便利。ここでは、見通しが悪くなるのを防ぐために Block の生成は別ファイルに切り出している。世の中に Block を作る便利そうなライブラリは色々あるが、依存を増やしたくないし大した労力でもないので自分は使っていない。Block の作成に手間取るようなら導入しても良さそう。
callback_id にはこのフォームが送信されたときの ID を入れておく必要があるので、コールバックを定義してる別ファイルからインポートして渡しておく。

2. フォームが送信されたらプレビューと確認ボタンを返す

view-callback.tsimport type { Middleware, SlackViewMiddlewareArgs, SlackViewAction, ViewOutput } from '@slack/bolt'
import { publishActionID } from './button-callback'

// フォームのバリデーションエラーの可能性があるキーを列挙しておく(フォームの Block の ID)
type ErrorMap = {
  title?: string
  description?: string
  post_id?: string
}

const validateInput = (state: ViewOutput['state']): ErrorMap => {
  // 略
}

type Record = {
  // 略
}

const createRecord = (state: ViewOutput['state']): Record => {
  // 略
}

export const modalCallbackID = 'article_form_callback'
export const modalCallBack = (store: Map<string, Record>): Middleware<SlackViewMiddlewareArgs<SlackViewAction>> => {
  const { state } = view // view(さっきのフォーム)の内容を受け取る
  const errors = validateInput(state) // 適当に入力をバリデーションする

  // エラーがある場合は、エラーとともに ack を呼ぶ。
  if (Object.keys(errors).length) {
    await ack({
      response_action: 'errors',
      errors,
    })
    return
  }
  // 問題なければ ack
  await ack()
  
  // store に state の内容を保存
  const record = createRecord(state) // state を整えて返す関数
  store.set(view.id, record)

  const message = 'hoge' // Slack に返す preview 用文字列を入れる  
  const buttonPayload: ButtonPayload = {
    message,
    viewID: view.id,
  }

  // 特定のチャンネルに、プレビューの文字列と記事公開ボタンを返す
  await client.chat.postMessage({
    channel: env.CHANNEL_ID,
    text: 'Publish article confirmation',
    blocks: [createMarkdownBlock(message), createButtonBlock(publishActionID, 'Publish!', JSON.stringify(buttonPayload))],
  })
}

ここではバリデーションして、問題なければプレビューと記事公開ボタンを返すだけ。
バリデーション結果はフォームの Block ID に対してエラーメッセージを指定することで、フォームに赤文字で表示される。注意すべきなのが、ack は3秒以内に返さなければいけない。(リクエストの確認 (Slack | Bolt for JavaScript))
例えば、記事のカテゴリ ID を入力させる場合、入力された ID のカテゴリが存在するのかこの時点で確認しておくべきだが、カテゴリの有無を API に確認するのに時間がかかりすぎてしまうと、フォームの送信が失敗したとみなされてしまう。

 

ボタンの Block にはコールバックに渡ることになる値をセットできる。大きすぎるデータはセットすることだけならできるが、実際にコールバックが発火するときにエラーになる。
そこで、本文を含めた state の内容を整形して、store に渡している。
今回は15分間だけメモリ上に保存しておける自作のストアを使う。実装の簡単さと利用しやすさのため、JavaScript の標準組み込みオブジェクトである Map のインターフェースを満たすようにしてあるがここでは内容は割愛。

また、今回もボタンの Block を作成するときにそのボタンのコールバック ID をセットする必要があるので、それはコールバックを定義してる別ファイルからインポートして渡しておく。

3. 確認ボタンを押されたら API を叩く / 4. 問題なく API を叩けたら結果を Slack に返す

button-callback.tsimport type { Middleware, SlackActionMiddlewareArgs, BlockAction, Block, KnownBlock } from '@slack/bolt'
import type { WebClient } from '@slack/web-api'
import type { ButtonPayload, Record } from './view-callback'
import { createPlainTextBlock, createMarkdownBlock } from './lib/blocks'
import { env } from './lib/env'

export const publishActionID = 'publish-action'

const chatUpdate = async (client: WebClient, ts: string, blocks: (KnownBlock | Block)[]) => {
  await client.chat.update({
    channel: env.CHANNEL_ID,
    ts,
    text: 'Publish article confirmation',
    blocks,
  })
}

export const publishAction = (store: Map<string, Record>): Middleware<SlackActionMiddlewareArgs<BlockAction>> => async ({ ack, say, payload, client, body }) => {
  await ack()

  // ボタンの payload を受け取る
  const buttonPayload = JSON.parse(payload.value) as ButtonPayload
  // ストアに保存しておいた record を取り出す
  const record = store.get(buttonPayload.viewID)
  // ストアにないということは15分過ぎてるのでその旨を返す
  if (!record) {
    await chatUpdate(client, body.message?.ts, [
      createMarkdownBlock(buttonPayload.message),
      createPlainTextBlock(`😢 Timeout. Please re-post the article.`),
    ])
    return
  }

  // ボタンを消して、代わりに誰が押したのかを表示する
  await chatUpdate(client, body.message?.ts, [
    createMarkdownBlock(buttonPayload.message),
    createMarkdownBlock(`<@${body.user.id}> clicked publish button!`),
  ])

  const newArticle = await (async () => {
    try {
      // ここで API を呼ぶ
      return await publishArticle(env.API_KEY, {
        ...record,
        published_at: new Date().toISOString(),
      })
    } catch (error) {
      console.error(error)      
    }
  })

  if (!newArticle) {
    await say('⚠️ Failed to upload new article.')
    return
  }

  await say(`🎉 New article has been published!\n ${newArticle.url}`)
}

ここで便利なのが、 client.chat.update である。
タイムスタンプでそのチャンネルからメッセージを特定し、内容を更新することができる。
記事公開ボタンを押した際のエラーや押したのが誰なのかといった情報とともに内容を更新し、記事公開ボタンを消してしまうことで、何度も同じボタンを押されてしまうことを防ぐことができる。
最後に、たった今公開したばかりの記事の URL を Slack に返してあげることで、作業者が簡単に結果を確認できるようにする。

それぞれの処理を Bolt に登録する

最終的に index.ts はこのようになる。

index.tsimport { App, ExpressReceiver } from '@slack/bolt'
import { articleFormShortcut } from './article-form-shortcut'
import { modalCallbackID, modalCallback, Record } from './view-callback'
import { publishActionID, publishAction } from './button-callback'
import { env } from './lib/env'
import { createStore } from './lib/store'

const receiver = new ExpressReceiver({
  signingSecret: env.SLACK_SIGNING_SECRET,
})
// health check 
receiver.app.get('/healthz', (_, res) => {
  res.status(200).send('healthy')
})

const app = new App({
  token: env.SLACK_BOT_TOKEN,
  receiver,
})

const store = createStore<string, Record>(15 * 60)
app.shortcut('publish-article', articleFormShortcut)
app.view(modalCallbackID, modalCallback(store))
app.action(publishActionID, publishAction(store))

;(async () => {  
  await app.start(env.PORT)
  console.log(`⚡️ Bolt app is running!: ${env.PORT}`)
})()

それぞれを登録すれば良い。
あとはデプロイして、Slack App の管理画面からエンドポイントやショートカットの登録などを行う。
Bolt は例外処理を忘れると普通にプロセスが落ちるので、デプロイする際はエラーログ周りをきちんとしたり、プロセス監視をする必要があるかもしれない。
自分はとりあえずその両方を forever で賄っている。(デーモン化した上で、標準エラー/標準出力をファイルに残したり、落ちた場合に再起動をしてくれる)

まとめ

今回の Slack App はグローバルショートカットを利用しているが、確認ボタンの投稿先のチャンネルを指定しているため、確認ボタンを押せるのはそのチャンネルに招待されている人限定となる。そのため、チャンネルのメンバーを管理することで投稿作業ができるメンバーを限定できる。
また、今回は Slack 上で最低限のプレビューを出すようにしたが、API からプレビュー用の一時 URL をもらえるようにしたりすると、実際のサービスでの見え方を確認してから投稿ボタンを押せるようになる。
Bolt の使い方にさえ慣れてしまえば、

  • 見た目は Block Kit Builder で作る
  • Bolt の コールバックだけ TypeScript で書く
  • 本来のビジネスロジックは API が担う

といった、楽な開発ができる。序盤に書いたとおり依存パッケージも少なく、フロントエンドを伴う Node.js アプリを作るより遥かにメンテが楽に感じている。Block も割と豊富でできることも多いが、画像やファイルのアップロード機能はないため、その辺は特定のチャンネルに上がった画像をフックするなど工夫が必要。
(画像投稿が Block でできないのは記事投稿的には致命的な気もするが、まあ入稿システムがないよりはマシということで。)

 

以上です。