Slack の スラッシュコマンドを Lambda 上の Haskell で書いてみる


Slack のスラッシュコマンドを、API Gateway を介した Lambda 上の Haskell プログラムとして実装するやりかた。

動機

使えるところからちょっとずつ使って、Haskell とか関数型プログラミングとかの適用範囲をじわじわ広げていきたい。

お題

/f-to-c [文字列] と入力すると、文字列部分を華氏温度と解釈して、それを摂氏に変換した数値を返すスラッシュコマンドを定義してみる。[文字列]を指定していなかったり、数値に解釈できないものが入っていたりすると、エラーメッセージが返るようにもする。

実務上の応用としては、例えば、プログラマなら手元の REPL 上でのちょっとした計算や採番で導出できる ID などを、非プログラマのチームメンバーでも Slack で簡単なコマンドを叩けば必要な結果が得られるような簡単な ChatOps がある。もちろん頑張れば外部システムなども絡めたもっと複雑なコマンドも実装できる。

構成

大きく3つの部分からなる。

  • simple-f-to-c: Haskell 版カスタムランタイム上で、F°から C° に変換する Lambda 関数
  • simple-f-to-c-app: UI としてスラッシュコマンドを提供する Slack App
  • simple-f-to-c-api: Lamda と Slack App をつなげる API Gateway

やること

次のような段取りで進めてみる。

プロジェクトテンプレートを得る

Haskell のカスタムランタイムは NickSeagullさんのこれを使う。同 Github で Stack のテンプレートも提供されているので、13.25 版の lts と組み合わせて使ってみる1

以下のようにしてテンプレートプロジェクト(simple-slash-command という名前にした)を作成する。

$ stack new simple-slash-command \
  https://raw.githubusercontent.com/theam/aws-lambda-haskell-runtime/6981fc0e2c6e86c8f3022376a778aafab45a0a71/stack-template.hsfiles \
  --resolver=lts-13.25 \
  --omit-packages

下記のようなファイルが生成されるが、、、

$ cd simple-slash-command
$ tree
├── app
│   └── Main.hs
├── LICENSE
├── Makefile
├── package.yaml
├── README.md
├── Setup.hs
├── simple-slash-command.cabal
├── src
│   └── Lib.hs
└── stack.yaml

ビルドする前に、以下の要領で stack.yaml を手編集する必要がある。

まず packages: [] の行を以下のように書き換える。

packages:
- .

さらにコメントアウトされている # extra-deps: [] の行を以下のように書き換える2

extra-deps:
- aws-lambda-haskell-runtime-1.0.10

あと初回だけビルド用の Docker イメージをプルしておく必要がある。

$ stack docker pull

ひとしきり待つと、stack new で指定した lts バージョンのビルド用イメージ fpco/stack-build:lts-13.2 が使えるようになる。

この時点で以下のようにビルド開始できる。

$ make

build フォルダに function.zip が生成されたはず。

テンプレプロジェクトの動作確認

上で作った .zip を一旦デプロイして動作確認してみる。基本的にはこの手順通り。AWS コンソールで作業してみる。

  • AWS コンソールで Lambda を開く。
  • [Create Function] ボタン押下から以下の入力を開始
    • Function name: simple-f-to-c
    • Runtime: カスタムランタイム Use Default Bootstrap
    • Permissionsロール: 'Create a new role with basic lambda permissions' でも良いし、Lambda が使える既存のロールがあればそれでも良い。
    • [Create Function] ボタン押下
  • Function Code から、、、
    • Code Entry Type で Upload a .zip file を選択
    • [Upload] ボタン押下 → 上で生成した function.zip を指定
    • Handler に src/Lib.handler を指定。
    • 一旦 [Save]
  • Designer ペインで Layers を選択して
    • Layers ペインの [Add Layer]ボタン押下
    • provide a layer version ARN を選択して
    • 右を入力 arn:aws:lambda:ap-northeast-1:785355572843:layer:aws-haskell-runtime:2 (ここでは ap-northeast-1 を指定した)
    • [Add] 押下
  • 再度 [Save]

ここまででテンプレの Lambda がデプロイできたので、先に進む前にテストする。

  • Confugure test event で、下記 Json を入力して適当なイベント名で保存。
{ "personAge": 43, "personName": "John Doe" }
  • [Test]ボタンを押下して、上手く動いていることを確認。

これをベースに、スラッシュコマンドを作っていく。

Lambda

テンプレ生成された Haskell ファイルはapp/Main.hs と src/Lib.hs の2つがあるが、Main.hs は Lib.hs で定義されるハンドラにリクエストをディスパッチするだけなので、基本的には後者の Lib.hs を編集していく。

Lib.hs

まず、リクエストとレスポンスを以下のように定義する。

...
import GHC.Generics (Generic)
import Data.Aeson (FromJSON, ToJSON)
...

data Request = Request { input :: String } deriving (Generic)
instance FromJSON Request

data Response = Response { result :: Double } deriving (Generic)
instance ToJSON Response

元のテンプレコードに倣って、Json ⇔ Haskell の変換に Aeson を使っている。

inputresult などのフィールド名は、あとで API Gateway でリクエスト/レスポンスとマッピングするときにそのまま使う(後述)。Request.input には、Slack から渡される "name1=value1&name2=value2" のような形の URLクエリ文字列が入ってくることになる。

この RequestResponse を受け渡しするハンドラ関数3 は以下のように定義できる。

...
import Aws.Lambda.Runtime (Context)
...

handler :: Request -> Context -> IO (Either String Response)
handler request context =
  return (fmap Response $ processInput $ input request)

流れとしては見ての通り、渡された Request から input を取り出して、processInput 関数に渡して、結果を Response にくるんでから、IO にして返すというもの。

processInput は、実際に Slack から送られたクエリ文字列をパーズして、摂氏温度への変換を試みる関数で、以下のように書いてみた。

...
import Control.Monad (mfilter, (>=>))
import qualified Data.Map as M (lookup, fromList)
import Text.Read (readMaybe)
import Data.Either.Extra (maybeToEither)
import Data.String.Utils (strip)
import Data.ByteString.Char8 (pack, unpack)
import Network.HTTP.Types.URI (parseSimpleQuery)
...

processInput :: String -> Either String Double
processInput= lookupText >=> tryConvert
  where
    lookupText :: String -> Either String String
    lookupText  = maybeToEither "no text"
                . (mfilter (not . null))
                . (fmap (strip . unpack))
                . M.lookup "text"
                . M.fromList
                . parseSimpleQuery
                . pack

    tryConvert :: String -> Either String Double
    tryConvert  = maybeToEither "couldn't convert"
                . fmap fahrenheitToCelsius
                . readMaybe

fahrenheitToCelsius :: Double -> Double
fahrenheitToCelsius d = (d - 32) * 5 / 9

クエリ文字列から text=[文字列] の文字列部分を取り出そうとする lookupText と、取り出した text 値からの F°→C° 変換を試みる tryConvert の2つのローカル関数からなる。どちらも失敗時にはエラー文字列を含む Left 値を返すクライスリなので、fish operator >=> で合成している。

Lib.hs 全体のソース

package.yaml

上記コードが依存しているライブラリ群は package.yaml 内の dependencies に追記しておく。

- utf8-string
- bytestring
- containers
- http-types
- extra
- MissingH

動作確認

デプロイする前に一応ローカルで動作確認しておく。

プロジェクト直下で $ stack ghci src/Lib.hs と打つと 上記コードが読まれた状態で REPL が開くので、下記のように試行できる。

*Lib> processInput "text=100"
Right 37.77777777777778
*Lib> processInput "text="
Left "no text"
*Lib> processInput "text=abc"
Left "couldn't convert"

TIPS : 最初から常時 REPL を活用しておくとやりやすい。

再デプロイ

  • ここまでのコードで再度 $ make して、function.zip を作り直しておく
  • AWS コンソールに戻って、function.zip を上げ直して [Save]
  • 下記のようなイベント JSON を発行して Test してみると、、、
{ "input": "text=100" }

こんな結果が返されるのを確認。

{
  "result": 37.77777777777778
}

API Gateway

上の Lambda 関数を、Slack App から利用できるように API Gateway で公開する。

リソースとメソッドの作成

  • AWS コンソールの API Gateway を開く
  • [Create API] ボタン押下
  • API name に適当な名前を指定して[Create]ボタン押下。ここではsimple-f-to-c-apiとした。
  • Actions から Create Resource
  • Resource Name を適当に指定。ここではf-to-cとした。Resource Path も/f-to-cを受け入れる。
  • Resources ツリーでf-to-cが選択された状態で、[Action]→[Create Method]
  • プルダウンからANYを選んでチェックボタン押下
  • 右側のペインで、Lambda Function に、上で作ったsimple-f-to-cを指定して[Save]。
  • ポップアップする 「Add Permission to Lambda Function」で [OK]。

マッピング

Lambda の入出力と、Slack スラッシュコマンドの入出力の形式を API Gateway のマッピングで合わせる。

まず Integration Request。

  • [Integration Request]→[Mapping Template]→[Add mapping template] を順にクリック。
  • Content-Type に application/x-www-form-urlencoded を指定してチェックボタン押下。
  • ポップアップする「Change passthrough behavior」で [OK]。
  • テンプレートを記述するテキストエリアが表示されるので下記を入力。
{
  "input" : $input.json('$')
}

"input" が Haskell コードの Request.input に対応する。

次に Integration Response

  • [Integration Response] 押下後、200 の行(この1行しかないはず)をクリックして展開
  • Maping Template の右向き三角クリックで展開
  • application/json を削除してから [Add mapping template]リンク押下
  • text/plain を入力してチェックボタン押下
  • 右側に開くテキストエリアに以下を入力し[Save]
#set($inputRoot = $input.path('$'))
#if ($inputRoot.result == "")
  ERROR: $input.json('$')
#else
  $input.json('$.result')
#end

関数の結果型が、Either String Response なので、成功して Response 型になった場合と、失敗して String になった場合とで切り分けた。resultResponse.result に対応している。

デプロイ

  • Resources で / 〜 /f-to-c 〜 ANY が選択された状態で、Actions から Deploy API クリック。
  • [Deployment stage] プルダウンで[New Stage]を選択し適当なステージ名を入力。ここでは dev とした4

この時点で、Stages が選択された状態でツリー上の dev 〜 / 〜 /f-to-c 〜 GET などを選択すると、下記のような Invoke URL が表示される。これを次に定義するスラッシュコマンドの向き先として使う。

https://〜.execute-api.ap-northeast-1.amazonaws.com/dev/f-to-c

Slack App

  • ブラウザで https://api.slack.com/apps を開く(アカウントなどはすでにある前提)
  • [Create New App] 押下
  • App Name に simple-f-to-c-app など適当に指定。インストール対象のワークスペースもここで指定。
  • 次の画面で [Slach Command] を選択して、[Create New Command] 押下
    • Command: f-to-c。これを Slack で入力する。
    • Request URL: 上で作った API Gateway の Invoke URL を指定
    • Short Description: convert fahrenheit to celsius などと適当に入力。
    • [Save]押下
  • [Basic Information]→[Building Apps for Slack]→[Install your app to your workspace]
  • [Install App to Workspace] 押下

ここまでの作業が上手く行っていれば Slack からのスラッシュコマンドが効くはず。

テスト

以下、"/f-to-c 100"、"/f-to-c abc"、"/f-to-c " を、順に一個ずつ入力した結果。

上手く行った。

所感・展望

  • Custom Runtime 以前から、Haskell で Lambda コードが書ける Serverless Framework 版があったはずだけど、今日は Hackage が落ちていて閲覧できない。
  • 本当は仕事で使ってる GCP の Cloud Functions を Haskell で書きたいのだけど、こっちはまだ時期尚早ぽい。
  • しばらくしたら runtime の ver2.0.0 が使えるようになりそうなので(ソースを読むといろいろ変わりそう)、そうしたらこの記事もアップデートする。
  • API Gateway の マッピングが意外とむずい。

  1. ただし現時点(2019/06/23)の最新版は、まだ runtime が公開されていないようなので、コミット履歴をさかのぼって見つけた動くものを利用する。 

  2. Runtime の Github README.md では 2.0.0 が指定されていたが、Hackage の Versions にはまだ含まれておらず、公開されている最新の 1.1.0 でもまだテンプレのソースコードがビルドに失敗するので、更にもう一つ古い 1.0.10 版を使っている(2019/06/23)。 

  3. ハンドラ関数自体は、stack new で生成されたプロジェクトテンプレの app/Main.hs 内の configureLambda で動的に生成されたディスパッチャーから呼ばれるらしいが、詳細は未調査。 

  4. 次回以降は New Stage ではなく既存の Stage を選択できる。