Haskell
Heroku
Slack

Slack BOTから始めるHaskell

More than 1 year has passed since last update.


はじめに

自分のHaskell力の低さを痛感している今日このごろ。

仕事で使う言語はHaskellではないものの、複数のプログラミング・パラダイムに触れているということはプラスに働くと考えているため、学習の機会は設けておきたいもの。

しかし自分はLazyで学習を先送りにしてしまうタイプ。怠慢、ストイックさの欠如。

これは由々しき自体です。どうすれば良いのか。

対策として、覚えたい言語に触れる機会を創出してしまうというのがあると思いますが、ちょうど以下の記事にインスパイアされて個人用のSlack BOTを作ってライフハックしてみようかと思っていたので今回の開発ターゲットとしました。

割と本気で家庭用Slack Botを作ってみた


まずはゆるく始める


構成

ひとまずは、以下のようにSlackからHubotを介してHaskellサーバにアクセスするようにしたいと思います。

[Slack] <-> [Hubot Server] <-> [Haskell Server]

実際Hubotは不要なのですが、今回はSlackのReal Time Messaging APIよりもHaskellを覚えたいというのが正直なところで、その部分はHubotに担ってもらおうという魂胆です。

イメージとしては、Hubot ServerがL7のルータのような感じでしょうか。

また、Hubotのプラグインに手頃なものがあれば簡単に取り入れられます(そしてHaskell学習が捗りませんね!)。

ひとまず無料にしておこうということで、Hubot、Haskellの各サーバにはHerokuを用いました。

また、HaskellのフレームワークにはSpockを用いることとしました。

候補としてはYesodが主流なのでしょうか。

しかし今回はシンプルなもので始めたかったということと、ScottyのチュートリアルがStackではなく生Cabalを使っていたので、Spockにしました。


やったこと


Haskell側 基本のスタンバイ


まずはHello Worldまで

https://www.spock.li/tutorial/ を見つつ進めました。

ので、しばらく上記チュートリアルを日本語にして端折った内容が続きます。

$ stack new Spock-example

しばらく待ちます。

できあがったら、Spock-exampleディレクトリに移動します。

そしてstack.yamlおよびSpock-example.cabalに依存関係としてSpockを追加します。

diff --git a/Spock-example.cabal b/Spock-example.cabal

index ff3741e..b93ee1a 100644
--- a/Spock-example.cabal
+++ b/Spock-example.cabal
@@ -25,6 +25,9 @@ executable Spock-example-exe
ghc-options: -threaded -rtsopts -with-rtsopts=-N
build-depends: base
, Spock-example
+ , Spock >=0.11
+ , mtl
+ , text
default-language: Haskell2010

test-suite Spock-example-test
diff --git a/stack.yaml b/stack.yaml
index c1fe192..4ffd28b 100644
--- a/stack.yaml
+++ b/stack.yaml
@@ -37,6 +37,13 @@ resolver: lts-7.14
# will not be run. This is useful for tweaking upstream packages.
packages:
- '.'
+- location:
+ git: https://github.com/agrafix/Spock.git
+ commit: 77333a2de5dea0dc8eba9432ab16864e93e5d70e
+ subdirs:
+ - Spock
+ - Spock-core
+ - reroute
# Dependency packages to be pulled from upstream that are not in the resolver
# (e.g., acme-missiles-0.3)
extra-deps: []

いったんビルドします。

$ stack build --fast --pedantic

README.mdがない、みたいな警告が出ますがスルーします。

そして、app/Main.hsの中身をまるっと以下で置き換えます。

{-# LANGUAGE OverloadedStrings #-}

module Main where

import Web.Spock
import Web.Spock.Config

import Control.Monad.Trans
import Data.Monoid
import Data.IORef
import qualified Data.Text as T

data MySession = EmptySession
data MyAppState = DummyAppState (IORef Int)

main :: IO ()
main =
do ref <- newIORef 0
spockCfg <- defaultSpockCfg EmptySession PCNoDatabase (DummyAppState ref)
runSpock 8080 (spock spockCfg app)

app :: SpockM () MySession MyAppState ()
app =
do get root $
text "Hello World!"
get ("hello" <//> var) $ \name ->
do (DummyAppState ref) <- getState
visitorNumber <- liftIO $ atomicModifyIORef' ref $ \i -> (i+1, i+1)
text ("Hello " <> name <> ", you are visitor number " <> T.pack (show visitorNumber))

そして以下のコマンドで再びビルドした上でサーバを起動します。

$ stack build --fast --pedantic

$ stack exec Spock-example-exe

動作確認してみます。

$ curl http://localhost:8080

Hello World!
$ curl http://localhost:8080/hello/test
Hello test, you are visitor number 1

というわけでHello Worldに成功しました。


Herokuにデプロイする

とりあえず無料で稼動させたいので、とりあえずのHerokuです。

今回はbuildpackとして https://github.com/mfine/heroku-buildpack-stack を使うことにしました。

$ heroku create --buildpack https://github.com/mfine/heroku-buildpack-stack.git

続いて、Herokuで動作するようにコードを書き変えます。

diff --git a/app/Main.hs b/app/Main.hs

index cfda0b5..5d8c433 100644
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -8,6 +8,7 @@ import Control.Monad.Trans
import Data.Monoid
import Data.IORef
import qualified Data.Text as T
+import System.Environment

data MySession = EmptySession
data MyAppState = DummyAppState (IORef Int)
@@ -16,7 +17,8 @@ main :: IO ()
main =
do ref <- newIORef 0
spockCfg <- defaultSpockCfg EmptySession PCNoDatabase (DummyAppState ref)
- runSpock 8080 (spock spockCfg app)
+ port <- getEnv "PORT"
+ runSpock (read port::Int) (spock spockCfg app)

app :: SpockM () MySession MyAppState ()
app =

Procfileの中身はこうします。

web: Spock-example-exe

コミットしたらHerokuにpushします。

$ git push heroku master

動作確認をします。

$ heroku open

ブラウザに Hello World! と表示されれば成功です。


Hubot側 基本のスタンバイ

Hubot、Heroku、Slack等のキーワードでググるといろいろ出てくるのでその情報で何とかします(雑)。

Slack連携まで行い、Slack上でhubot pingと打ってPONGと返るところまで進めればOKです。

そして、scripts/haskell.coffeeとして以下の内容を記述し、改めてHerokuにデプロイします。

module.exports = (robot) ->

robot.respond /hs/i, (msg) ->
robot.http(先ほどのHaskellサーバのトップページURL)
.get() (err, res, body) ->
if res.statusCode is 200
msg.send body
else
# エラーハンドリングは上手いことやっておく
msg.send 'haskell server has something wrong'

これでSlackからhubot hsと打ち、Hello World!と返れば実験は成功です。

Haskellのバックエンドを持ったHubotが生まれました。

ここまで来れば、後はどうとでもなるでしょう。


しかし納得できなかったので

やはりHubot、Haskellのプロセス間通信がサーバをまたぐ必要性はないなと。

今回のアイディアのインスパイア元ブログの別記事である、HubotスクリプトをCommon Lispで書くに改めてインスパイアされつつ、サブプロセスでHaskellの処理を呼ぶ形式にトライしてみました。


サブプロセス式: ローカルで動かすまで

$ stack new haskellbot

$ cd haskellbot
$ yo hubot

Bot Adapterはslackとします。

HaskellとHubotのコードが混ざり合うのが微妙な気がしますが…いったんこれで進めます。

app/Main.hsを以下の内容にします。

module Main where

import Data.Char(toUpper)

main :: IO ()
main = interact (map toUpper)

ビルドします。

$ stack build

試しに実行してみましょう。

$ stack exec haskellbot-exe

ここで入力した半角英字が大文字になれば成功です。

続いて、scripts/haskell.coffeeを作成します。

module.exports = (robot) ->

robot.respond /hello/i, (res) ->
spawn = require('child_process').spawn
stack = spawn('stack', ['exec', 'haskellbot-exe'])
stack.stdout.on('data', (data) ->
res.send data
)
stack.stdin.write(res.message.text)
stack.stdin.end()

ではBOTから実行してみましょう。

$ bin/hubot

そしてhaskellbot helloと打ち、HASKELLBOT HELLOと返ってくれば成功です。


Herokuへのデプロイは悔しい結果に

結論から言うと、動くところまで到達できないままアドベントカレンダーの期日を迎えてしまいました。

Docker力とStack力の低さが足を引っ張る形に。これはリベンジが必要です。

完成していないということでここから先を公開するかは正直迷ったのですが…とりあえず記録だけ残してきます。

これを読んだあなた。どうか真相を暴いてください。それだけが私の望みです。


最初はビルドパックでやってみた

作業としては、以下のコマンドを打ってみました。

$ heroku create --buildpack https://github.com/mfine/heroku-buildpack-stack.git

$ heroku buildpacks:add heroku/nodejs
$ git push heroku master

するとこんなエラーが。

bin/hubot: 5: bin/hubot: npm: not found

少し色々やってみたんですが、nodenpmにパスが通っていない印象でした。

heroku-buildpack-stackを外すとこのエラーが出なくなるので、ビルドパック内でパス周りの何かが起きているのでしょう。


そしてContainer Registryへ

というわけでDockerで攻めてみることにしました。

HerokuのContainer Registryを使うわけです。

これならHeroku固有の技術というわけでもありませんし、他のプラットフォームへの移行も視野に入ることでしょう。

まずはこんなDockerfileを用意しまして

FROM node:4-onbuild

ENV LANG C.UTF-8

RUN echo 'deb http://ppa.launchpad.net/hvr/ghc/ubuntu trusty main' > /etc/apt/sources.list.d/ghc.list && \
echo 'deb http://download.fpcomplete.com/debian/jessie stable main'| tee /etc/apt/sources.list.d/fpco.list && \
# hvr keys
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F6F88286 && \
# fpco keys
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys C5705533DA4F78D8664B5DC0575159689BEFB442 && \
apt-get update && \
apt-get install -y --no-install-recommends cabal-install-1.24 ghc-8.0.1 happy-1.19.5 alex-3.1.7 \
stack zlib1g-dev libtinfo-dev libsqlite3-0 libsqlite3-dev ca-certificates g++ git && \
rm -rf /var/lib/apt/lists/*

ENV PATH /root/.cabal/bin:/root/.local/bin:/opt/cabal/1.24/bin:/opt/ghc/8.0.1/bin:/opt/happy/1.19.5/bin:/opt/alex/3.1.7/bin:$PATH

CMD stack setup
CMD stack build
CMD bin/hubot -a slack

内容は以下を参考に。

そしてhaskell.coffeeを次のようにしたら、ローカルでは動作確認ができました。

module.exports = (robot) ->

robot.respond /hello/i, (res) ->
spawn = require('child_process').spawn
# ここを変更
# before: stack = spawn('stack', ['exec', 'haskellbot-exe'])
stack = spawn('.stack-work/install/x86_64-linux/ghc-8.0.1/8.0.1/bin/haskellbot-exe')
stack.stdout.on('data', (data) ->
res.send data
)
stack.stdin.write(res.message.text)
stack.stdin.end()

spawnのところで、stack exec haskellbot-exeとせずにパスを指定しないと上手く動かなかったという。

この辺で技術的な怪しさが漂っていますね。

ではHerokuにデプロイです。

$ heroku plugins:install heroku-container-registry

$ heroku container:login
$ heroku container:push web

結果としては、hubot pingには反応してくれたんですが、肝心のHaskellのプロセスを立ち上げるところでエラーになりました。

そしてここでタイムアップ。

無念ですが、Haskell力以外にも上げなければならないパラメータがあるようです。