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!