A Frost Warning Discord Bot Using OpenWeather API, Haskell, IHP, Wreq

, Haskell


A daily cronjob runs a Haskell script in IHP which gets weather forecast data from the OpenWeather One Call API, checks for temperatures below freezing point, and, in case of frost, sends a warning via a Discord bot.

Imports

#!/usr/bin/env run-script
module Application.Script.WeatherApiFrostwarning where

import Application.Script.Prelude hiding (get)
import Network.Wreq
import Control.Lens ((^?), (^..), folded)
import Data.Aeson
import GHC.Generics 
import Data.Aeson.Lens (key, values)
import qualified Data.Text as T
import qualified Application.Helper.Script as H (postToDiscord)
import Web.Controller.Prelude (Value(Number))
import Data.Scientific (Scientific)

We use the HTTP client library Wreq to get the data, which uses Lens. We hide get from Prelude to use Wreq‘s. The message for discord is JSON encoded with Aeson. The Discord client code is placed in a module Application.Helper.Script to handle this in line with the corresponding View and Controller helpers, which are available app-wide.

The main function to run the script

This function can be run locally with %> Application/Script/WeatherApiFrostwarning.hs, it should be placed in IHP’s script directory. In case your IHP is hosted on IHP Cloud, you can select it on the Settings page for a cronjob. I’m running it daily at morning time to have the whole day to prepare. If you run it hourly, you’ll get spammed in case of frost ahead…

run :: Script
run = do
    minTemp24 <- getMinTempNext24Hours openWeatherUrl
    
    if minTemp24 < Number 0
        then H.postToDiscord (postBodyFrostwarning minTemp24)

We call a function to get the lowest temperature for the next 24 hours, and we call our Discord bot in case of frost, passing along the temperature value.

This is the URL for my location; see OpenWeather Docs.

openWeatherUrl :: String
openWeatherUrl = "https://api.openweathermap.org/data/2.5/onecall?lat=52.00738778924009&lon=8.823055903576584&exclude=current,minutely,daily,alerts&appid=a44bf38a181fb428e16ab64d0639a2ba&units=metric&lang=de"

Accessing the response and converting to a Haskell value

Documentation on Wreq is thin. I didn’t find much apart from the the author’s own tutorial. Chris Penner’s interesting post Generalizing ‘Jq’ And Traversal Systems Using Optics And Standard Monads, which isn’t even about Wreq, helped me to get it done.

getMinTempNext24Hours :: String -> IO Value
getMinTempNext24Hours url = do
    r <- get openWeatherUrl
    let minTemp24  = r ^? responseBody  . key "hourly" 
                     ^..folded . values . key "temp" 
                     |> take 24 
                     |> minimum
    pure minTemp24

Without digging into Lens which I’m not even beginning to get, the response r is here accessed by stepping inwards along the lenses wreq produces from the JSON response. If you want to get a feeling for it, try it step by step in GHCI. After importing the module, you still, for some reason, have to add Network.Wreq. in front of the get.

Sticking to IHP’s pipe style with its |> operator, we can apply take 24 (for we don’t want two days, but only one day = 24 hourly dates forecast) and minimum to the resulting number. The function gives back the min temperatur with type Value; comparing it to Number 0 in the run function works anyway.

Composing the warning message

postBodyFrostwarning temp = object [
    "content" .= T.unpack "<@&805016503044276236> **NIGHT FROST** " 
            ++ T.unpack temp_info ++ T.unpack " degrees"
    ]
        where
            Number temp_nr = temp
            temp_info = show temp_nr

We need to convert Text to String for Aeson’s .= operator, which makes JSON key value pairs. From temp, which is of type Value, we need to extract Text before, which I do in a plump manner.

The Discord bot

Finally, we want to post our message to Discord. Again, we use Wreq, Lens, Aeson.

module Application.Helper.Script where

import Application.Script.Prelude
import qualified Network.Wreq as Wreq
import qualified Control.Lens as Lens((^.), (.~), (&))
import qualified Data.Map as Map
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Lens as L
import GHC.Generics

postToDiscord body = do
    let opts = Wreq.defaults Lens.& Wreq.header "Authorization" 
               Lens..~ ["Bot <your_bot_token>"]
               
    r <- Wreq.postWith opts 
        "https://discord.com/api/webhooks/<your_client_id>/\
            \<your_discord_channel_webhook>" 
        (Aeson.toJSON body)

    let datas = r Lens.^. Wreq.responseBody . L.key "data" . L._String
        ua    = r Lens.^. Wreq.responseBody . L.key "headers" . L.key "User-Agent" . L._String
        ganzR = r Lens.^. Wreq.responseBody . L._String

The webhook id is the last part of the webhook URL from the integrations panel of your Discord channel, coming after the client id. The Wreq tutorial introduces the use of the header lens in order to authorize with Discord.

Thank you Berlin Haskell Users Group, thank you Lukas Penner, thank you Paul Biggar’s Dark lang from where this has been migrated!