Search code examples
haskellbotsirc

Extending the IRC bot from wiki.haskell.org with state


Problem

I'm attempting to extend the IRC bot from https://wiki.haskell.org/Roll_your_own_IRC_bot with some state that's updated every time the bot posts a message in the channel it's connected to.

The feature is: every time the command !last said is issued in the IRC channel, the bot should respond with a time stamp. To support this, the privmsg function needs to update the bot's state -- specifically the lastPosted record -- with a new timestamp every time it is called.

Work so far

I took the code from the bottom of the Haskell wiki page (which used a ReaderT to access information about the bot's environment) and tried to change out the ReaderT for a State Transformer (StateT). The results are below and as you can see, I didn't get very far.

import Data.List
import Network
import System.IO
import System.Exit
import System.Time
import Control.Arrow
import Control.Monad.State
import Control.Exception
import Text.Printf

server = "irc.freenode.org"
port   = 6667
chan   = "#testbot-test"
nick   = "testbottest"

-- The 'Net' monad, a wrapper over IO, carrying the bot's immutable state.
type Net = StateT Bot IO
data Bot = Bot { socket :: Handle, lastPosted :: ClockTime }

-- Set up actions to run on start and end, and run the main loop
main :: IO ()
main = bracket connect disconnect loop
  where
    disconnect = hClose . socket
    loop st    = runStateT run st

-- Connect to the server and return the initial bot state
connect :: IO Bot
connect = notify $ do
  h <- connectTo server (PortNumber (fromIntegral port))
  t <- getClockTime
  hSetBuffering h NoBuffering
  return (Bot h t)
    where
      notify a = bracket_
        (printf "Connecting to %s ... " server >> hFlush stdout)
        (putStrLn "done.")
        a

-- We're in the Net monad now, so we've connected successfully
-- Join a channel, and start processing commands
run :: Net ()
run = do
  write "NICK" nick
  write "USER" (nick ++ " 0 * :test bot")
  write "JOIN" chan
  gets socket >>= listen

-- Process each line from the server
listen :: Handle -> Net ()
listen h = forever $ do
  s <- init `fmap` liftIO (hGetLine h)
  liftIO (putStrLn s)
  if ping s then pong s else eval (clean s)
  where
    forever a = a >> forever a
    clean     = drop 1 . dropWhile (/= ':') . drop 1
    ping x    = "PING :" `isPrefixOf` x
    pong x    = write "PONG" (':' : drop 6 x)

-- Dispatch a command
eval :: String -> Net ()
eval     "!quit"               = write "QUIT" ":Exiting" >> liftIO (exitWith ExitSuccess)
-- Posting when something was last posted shouldn't count as last posted.
eval     "!last said"          = getLastPosted >>= (\t -> write "PRIVMSG" (chan ++ " :" ++ t))
eval x | "!id " `isPrefixOf` x = privmsg (drop 4 x)
eval     _                     = return () -- ignore everything else

getLastPosted :: Net String
getLastPosted = do
  t <- gets lastPosted
  return $ show t

-- Send a privmsg to the current chan + server
privmsg :: String -> Net ()
privmsg s = write "PRIVMSG" (chan ++ " :" ++ s)

-- Send a message out to the server we're currently connected to
write :: String -> String -> Net ()
write s t = do
    h <- gets socket
    liftIO $ hPrintf h "%s %s\r\n" s t
    liftIO $ printf    "> %s %s\n" s t

Other support avenues explored

  • spent a couple of days reading up on ReaderT, StateT and their non-transformer friends Reader and State,

  • checking Stack Overflow for anyone with a similar problem, but the only other IRC bot question threaded the socket as an argument to every function that needed it (instead of using a ReaderT),

  • Tweeted Don S. the original author of the wiki page

  • asked in the Haskell IRC channel.

Question

How can the Haskell wiki IRC bot be extended to post a message, containing the date and time stamp of the last message posted? Preferably using an abstraction like ReaderT (only allowing mutable state) rather than passing state around in function arguments.


Solution

  • I got your code to compile by simply adding a >> return () to the definition of loop in your main:

    main :: IO ()
    main = bracket connect disconnect loop
      where
        disconnect = hClose . socket
        loop st    = (runStateT run st) >> return ()
    

    This effectively ignores the return value of runStateT. Here are all of the variants of runState/runStateT:

    • runStateT - return both the final state and returned value
    • evalStateT - return only the final value
    • execStateT - return only the final state

    Your original definition of loop was returning a pair (from runStateT), and this didn't type check since main wants a computation which returns just ().

    To update the lastPosted field, consider this addition to the eval function which is triggered when the bot is sent the message !update time:

    eval "!update time"
         = do t <- liftIO getClockTime
              bot <- get
              put (bot { lastPosted = t })
    

    We need to liftIO getClockTime since we are operating in the Net monad. Then we get the old state and put the updated state. You can add this logic wherever you want to update the lastPosted time in the Net monad.

    Full code is available at: http://lpaste.net/142931