Writing a simple Telegram chat bot in Purescript

  • 4
  • 0

...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 Effs and Streams 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
    { 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 readProps, 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.
    (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
    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;
        origin: User,
        id: fromId
      console.log('got request from', fromId);
foreign import addMessagesListener :: forall e.
    (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 ->
    ( ref :: REF
    , cp :: CHILD_PROCESS
    | e
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 Streams, 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 Streams/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 Effs to produce Streams.

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


Hopefully this has been kind of useful to see how you can write your own Purescript programs and how you can use Effs and Streams 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.