...a follow-up to "Async in Purescript is fun and easy"
Last time I wrote an article about how async in Purescript is pretty fun and doesn't have to be hard, but that doesn't really give users who are unfamiliar with Purescript/Haskell/Elm a good sense of what writing a normal application looks like using those async tools. In this post, I'll go into a breadth of features that I used to make a simple Telegram chatbot and hopefully provide some examples of how to use Eff
s and Stream
s in the "context" of Aff
.
Getting to work
Reading configuration
My chatbot some configuration in a JSON file with the following properties:
-
token
- auth token for my Telegram bot -
torscraperPath
- path to my Torscraper program -
master
- my Telegram user id, for when I need to send messages that aren't replies
So I need to read in this file, parse the JSON, then extract the fields (while making sure they are set). Since this only requires one value, I use an Aff
here.
For reading the files, there is already a nice library, purescript-node-fs-aff. It's nice that we don't need to do this manually. We use readTextFile
which takes an encoding and a filepath.
readTextFile UTF8 "./config.json"
For parsing the result, there's also another nice library, purescript-foreign. This lets us parse JSON and start reading props from it.
parseConfig :: String -> Either ForeignError Config
parseConfig json = do
value <- parseJSON json
token <- readProp "token" value
torscraperPath <- readProp "torscraperPath" value
master <- readProp "master" value
pure
{ token: token
, torscraperPath: torscraperPath
, master: master
}
This seems meatier, but uses things we've seen before. In this do
block, each binding is the result of taking the Right
of our Either
representing a successful operation. Left
is a failure, and would short-circuit this to return the left side, which in this case is a ForeignError
. After all these readProp
s, we then lift our record of type Config
into the Either
as Right Config
.
For putting these together, we can use the generic map/<$>
operator on an Aff
to map a function to what is inside, and we get what we're looking for.
getConfig :: forall e. Aff (fs :: FS | e) (Either ForeignError Config)
getConfig = parseConfig <$> readTextFile UTF8 "./config.json"
The config parsing is a key point in our application's startup, so I'm going to make sure that the user is notified when they mess this up, which means that I'll be handling the Left
case of the Either
to notify the user their config file is malformed. The Right
case will resume as needed.
main = launchAff $ do
config <- getConfig
case config of
Left e -> liftEff' $ log "config.json is malformed. closing."
Right {token, torscraperPath, master} -> do
[...]
You'll see here that I've used liftEff'
, because I'm using log
from Control.Monad.Eff.Console
. This lifts the Eff
into an Aff
so that it can be used in the do
block of the launchAff
.
This will come up again and again, in this article and in other code you write.
Starting up our bot and bot methods
The actual Telegram API is humongous, and this being just a hacky project, I have taken many liberties to make this as easy as possible for me.
For one, I made connecting dead easy:
exports.connect = function (token) {
return function () {
return new TelegramBot(token, {polling: true});
};
}
type TelegramEffects e = (telegram :: TELEGRAM, console :: CONSOLE | e)
foreign import data TELEGRAM :: !
foreign import data Bot :: *
foreign import connect :: forall e. Token -> Eff (TelegramEffects e) Bot
I've made a convenience type to work from called TelegramEffects
, but importantly I've added TELEGRAM
as an effect and Bot
as a data type for my bot instance. My connect
function simply returns a bot instance running through the instance, and so usage looks like our logging function above:
bot <- liftEff $ connect token
With sending messages, I also have a relatively simple FFI function:
exports._sendMessage = function(bot, id, message) {
return function () {
bot.sendMessage(id, message);
};
}
foreign import _sendMessage :: forall e.
Fn3
Bot
Int
String
(Eff (TelegramEffects e) Unit)
This Fn3
thing may be unfamiliar to you. It's the uncurried function helper, so that you don't have to write fully curried FFI functions. Fn3
is of kind * -> * -> * -> * -> *
, which looks scary at first, but really means that it takes four arguments, which aligns to the three arguments and one return value. This has an accompanying runFn3
function for actually running these uncurried functions.
My sendMessage
then has some logic about when messages should actually be sent:
data RequestOrigin
= User
| Timer
type Request =
{ origin :: RequestOrigin
, id :: Id
}
sendMessage :: forall e. Bot -> Result -> Eff (TelegramEffects e) Unit
sendMessage bot {id, output, origin} = do
case origin of
Timer ->
case indexOf "nothing new to download" output of
Just _ -> log "timer found nothing" -- don't care about being notified here!
_ -> send
_ -> send
where
send = do
log output
runFn3 _sendMessage bot id output
My message listening function is the worst, since I was too lazy to properly set up anything, including traversing the complex object structure to get just a few pieces of information I need. I "leave this" as an "exercise for the reader" if they would like to implement this correctly:
exports.addMessagesListener = function (bot, User, eff) {
return function () {
bot.onText(/^get$/i, function (msg, match) {
var fromId = msg.from.id;
eff({
origin: User,
id: fromId
})();
console.log('got request from', fromId);
});
};
}
foreign import addMessagesListener :: forall e.
Fn3
Bot
RequestOrigin
(Request -> Eff (TelegramEffects e) Unit)
(Eff (TelegramEffects e) Unit)
Running our Torscraper
This is another case where we can take advantage of existing code written in purescript-node-child-process and purescript-node-streams. This allows me to spawn a child process and add some handlers like usual. I collect the output using a ref that I write to.
runTorscraper :: forall e.
String ->
Request ->
Aff
( ref :: REF
, cp :: CHILD_PROCESS
| e
)
Result
runTorscraper path request = makeAff \e s -> do
ref <- newRef ""
process <- spawn "node" ["index.js"] $
defaultSpawnOptions { cwd = Just path }
result <- try $ onDataString (stdout process) UTF8 \string ->
modifyRef ref $ append string
case result of
Right _ -> do
onError process $ toStandardError >>> e
onExit process \exit -> do
output <- readRef ref
s { id: request.id, origin: request.origin, output: output }
Left err -> e err
Putting it into Streams
Now that we have the pieces, we can start putting them into place.
The addMessagesListener
from before can be used with fromCallback
from purescript-xstream to create a Stream
. This fromCallback
actually returns a Eff e (Stream a)
, so we need to lift this like before.
requests <- liftEff $ fromCallback $ runFn3 addMessagesListener bot User
Similarly, we use periodic
from purescript-xstream and then map a const timerRequest
to it, so that every tick of the timer will just give us this static request.
let timerRequest = {id: master, origin: Timer}
timer <- liftEff $ periodic (60 * 60 * 1000)
let timer' = const timerRequest <$> timer
Then we need to take these two Stream
s, merge with another Stream
that will make sure this gets called when the bot starts up, and switch-map an Eff
. A switch-map is just like a normal flatMap, where you pass in a function of a -> f a
, but in the context of Stream
s/observables, the subscription to the previous provided Stream
is disposed. This is a bit of a dense explanation, but there are better explanations and examples online like this StackOverflow question.
So by using switchMapEff
in purescript-xstream, we can switch-map Eff
s to produce Stream
s.
results <- liftEff $ (requests <|> timer' <|> pure timerRequest) `switchMapEff` \request ->
fromAff $ runTorscraper torscraperPath request
The alt/<|>
operator is used here to merge all the values emitted by the streams.
Finally we can just lift the subscribe function and we're done:
liftEff' $ addListener
{ next: sendMessage bot
, error: message >>> log
, complete: const $ pure unit
}
results
Conclusion
Hopefully this has been kind of useful to see how you can write your own Purescript programs and how you can use Eff
s and Stream
s in the "context" of Aff
.
If you made it this far, thanks for reading! And please do send me corrections or suggestions, as I'm sure I do not have the best solutions here.
Links
- My Telegram bot code used in this post https://github.com/justinwoo/simple-rpc-telegram-bot/
- The previous post, "Async in Purescript is fun and easy" http://qiita.com/kimagure/items/2ebce1399bac00c79656
- The post about my Torscraper, "Making a simple node program with Purescript" http://qiita.com/kimagure/items/5674e3ae9c87262af762
- purescript-aff https://github.com/slamdata/purescript-aff/
- purescript-xstream https://github.com/justinwoo/purescript-xstream
- purescript-node-fs-aff https://github.com/purescript-node/purescript-node-fs-aff
- purescript-node-child-process https://github.com/purescript-node/purescript-node-child-process
- purescript-node-streams https://github.com/purescript-node/purescript-node-streams