Haskell
Slack
Aeson
slackbot

お天気Bot で理解する Haskell の便利パッケージ

概要

天気を取得して Slack にたれながす簡単なボットを作りました。名前は天気を予測することと、バイト先の会社名の連想から、Kaguya としました。

実行時のイメージ:

Screen Shot 2017-12-25 at 7.49.09.png

クリスマスは一日雨のようです。やーいざまぁみろリア充どもーとか思っていませんよ。非リアは心を無にして耐え忍ぶ日です。これぞ日本の SHINOBI ってやつです。

話を戻しますが、見て分かるように、こいつには以下の 2つの機能があります:

  1. 1日の天気を朝6時に流す
  2. 1時間に 1回、現在の天候と 3時間以内の予報を突き合わせ、天候が変化するようであれば、変化する天候と変化するまでの時間を流す

実行するためには、まず Slack に自作アプリをインストールして、OAuthトークンを取得する必要があります。ここでアプリを作成し、アプリを選択した画面で Install App を選択すれば OAuthトークンが表示されます。
また、天気予報を取得するにあたって OpenWeatherMap の APIトークンも用意する必要があります。アカウントを作って無料枠のものを取得してください。

次に、

$ git clone git@github.com:pythonissam/Kaguya.git

と叩いてクローンします。プロジェクト直下に docker-compose.yaml があるので、SLACK_TOKENOPEN_WEATHER_MAP_TOKEN を取得したものに変更しましょう。CHANNEL をいじれば好きなチャンネルに天気予報を流せます。CITY には自分の地域を指定してください(ここ参照けどめっちゃ重い)。

最後に、以下のコマンドを叩いてください (Docker Compose をインストールしていない人はインストールしてから実行してください):

docker-compose up -d

高レベルの説明

概要で上げた 2つの機能を実現するためには、大まかに分けて以下のことをする必要があります:

  1. 天気の取得 (OpenWeatherMap の API を叩く)
  2. OpenWeatherMap から返ってくる JSON のパース
  3. Slack に天気を投げる (Slack の API を叩く)

なので、API の叩き方と JSON の扱い方が分かればいけそうですね。前者は http-conduit, 後者は aeson というパッケージを使って実現します。この記事ではこの 2つのパッケージの使い方を踏まえつつ、実装の説明をざっくりとしていきます。

ちなみにこのプロジェクト、JSON のパースに失敗したりした場合、例外が投げられて即座に死ぬ仕様となっております。そういう場合も Docker Compose 側でコンテナが再起動するようにはなっていますが、あまり信頼性を期待しないでください。

実装の説明

天気の取得

まずは現在の天気からです。

class GForecast f where
  forecast :: IO f

instance GForecast Forecast where
  forecast = do
    token <- openWeatherMapToken <$> settings
    city  <- city <$> settings

    request <- parseRequest $ endpoint ++ "?q=" ++ city ++ "&appid=" ++ token
    manager <- newManager tlsManagerSettings
    runResourceT $ do
      response <- httpLbs request manager
      let value = fromJust . decode . responseBody $ response :: Value
          status = fromJust . extractStatus $ value
      return status
    where
      endpoint = "https://api.openweathermap.org/data/2.5/weather"

といってもそのまんまですね。リクエストを作ってレスポンスをもらうだけです。

次に天気予報です。

instance GForecast [Forecast] where
  forecast = do
    token <- openWeatherMapToken <$> settings
    city  <- city <$> settings

    request <- parseRequest $ endpoint ++ "?q=" ++ city ++ "&appid=" ++ token
    manager <- newManager tlsManagerSettings
    runResourceT $ do
      response <- httpLbs request manager
      let value = fromJust $ decode $ responseBody response
      fs <- liftIO $ (trunc8 <$> getCurrentTimeJST)
            <*> return (fromJust . extractForecasts $ value)
      return fs
    where
      endpoint = "https://api.openweathermap.org/data/2.5/forecast"
      trunc8 t = take 8 . dropWhile ((<t) . fromJust . time)

これも同じことをやっています。説明略です。

JSON のパース

この辺が一番おもしろい部分です。

まずは現在の天気のパースからやりましょう。さっきこんなコードがあったと思いますが、

let value = fromJust . decode . responseBody $ response :: Value
    status = fromJust . extractStatus $ value

まずレスポンスを decodeValue型に変換します。この型は JSON の型です。で、これを天気予報の型である、自前の Forecast型に変換します。Forecast型はこんな感じ:

data Weather = Clear | Clouds | Rain | Snow
data Forecast = Forecast
                { time    :: Maybe JSTTime
                , weather :: Weather
                , minTemp :: Scientific
                , maxTemp :: Scientific }

さっきの Value型の値がこの Forecast型の値を表現していたのなら、Forecast型を FromJSONクラスのインスタンスにしてもよかったでしょう。でも、今回は JSON から値を取り出しつつ Forecast型の値を生成するのが目標です。そのために extractStatus関数を用意しました。以下コードです:

extractStatus :: Value -> Maybe Forecast
extractStatus = parseMaybe $
  withObject "Forecast" $ \v -> Forecast
    <$> return Nothing
    <*> (v .: "weather"
         >>= withArray "Object" ((withObject "Object" return) . V.head)
         >>= (.: "main")
         >>= withText "Weather" (return . read . T.unpack))
    <*> (v .: "main"
         >>= withObject "Scientific" (.: "temp_min")
         >>= withScientific "Scientific" (return . subtract 273.15))
    <*> (v .: "main"
         >>= withObject "Scientific" (.: "temp_max")
         >>= withScientific "Scientific" (return . subtract 273.15))

わけわからんみゃーですね。順に説明していきましょう。まず、parseMaybe はこんな感じです:

parseMaybe :: (a -> Parser b) -> a -> Maybe b

ここでは、

parseMaybe :: (Value -> Parser Forecast) -> Value -> Maybe Forecast

ですね。なので、$ 以降はパーサです。パーサは Value型の値を受け取ってそれをごにょごにょして Parser Forecast型にしています。

withObject の型を示します:

withObject :: String -> (Object -> Parser a) -> Value -> Parser a

こいつは、Value型の値の Objectコンストラクタを外して、その中身の Object型の値に対して何かする関数です。第一引数はパースしようとしている型の名前です。Objectコンストラクタ以外の Value型の値が来たときに、エラーを吐くのに使われます。Value型は以下のような感じで、

data Value = Object !Object
           | Array !Array
           | String !Text
           | Number !Scientific
           | Bool !Bool
           | Null

withArraywithText なども用意されています。これが便利なのは、コンストラクタを勝手に剥いでくれるからです。これをパターンマッチでやってもいいですが...

parseMaybe $ \v -> do
  let (Object o)  = v
      (Object o2) = o .: "hoge"
      (Object o3) = o2 .: "fuga"

なかなかつらいですよね。こんなことをしてたらバイト先から解雇通告を受けそうです。

(.:) :: FromJSON a => Object -> Text -> Parser a

は、キーから値を取得する関数です。この説明はいいですね。

天気予報のパースも同じようにやっているので説明は割愛します。以上が aeson の説明でした。

Slack に投げる

まぁこれもエンドポイントに投げてるだけなので割(ryo

ポイントは setQueryString 使えばリクエストbody にパラメータを設定できるよ、ぐらいですかね。コード見れば雰囲気は分かると思います(投げやり)

注意点

  • http-conduit では日本語を扱えない疑惑がある
  • OpenWeatherMap の API は降水確率を提供していないので、割と致命的。結果降水確率が 10% とかでも予報では雨になってたりする