packages feed

tellbot-0.6.0.10: src/Main.hs

import Control.Concurrent ( threadDelay )
import Control.Exception ( SomeException, catch, try )
import Control.Monad
import Control.Monad.Except
import Control.Monad.Trans.RWS
import Data.Bifunctor ( bimap, second )
import Data.Char ( toLower )
import Data.Foldable ( toList )
import Data.List ( intersperse )
import Data.List.Split ( chunksOf, splitOn )
import Data.Foldable ( traverse_ )
import Data.Time.Clock ( getCurrentTime, utctDay )
import Data.Version
import qualified Data.Map as M
import HTML
import Network
import Text.Regex.PCRE ( (=~) )
import System.Environment ( getArgs )
import System.IO

version :: Version
version = Version [0,6,0,9] ["Apfelschorle"]

type Server     = String
type Chan       = String
type Session    = RWST ConInfo () Stories IO

-- IRC connection information.
data ConInfo = ConInfo {
    -- server host
    conHost   :: String
    -- chan
  , conChan   :: String
    -- our nick
  , conNick   :: String
    -- handle
  , conHandle :: Handle
    -- admin password
  , conPwd    :: String
  }

-- A 'Nick' is a case-insensitive 'String'.
newtype Nick = Nick { unNick :: String }

instance Eq Nick where
  Nick a == Nick b = map toLower a == map toLower b

instance Show Nick where
  show (Nick a) = map toLower a

-- For each individual person, keep a list of stories to tell them.
type Stories = M.Map String [String]

-- Port to use to connect to the IRC server.
ircPort :: Int
ircPort = 6667

-- Threshold limit we think we’ll be flooding a channel.
floodThreshold :: Int
floodThreshold = 3

-- If think we’re about to flood, delay messages by this value.
floodDelay :: Int
floodDelay = 500000 -- 500ms

-- Time to reconnect to a server.
reconnectDelay :: Int
reconnectDelay = 1000000 -- 1s

-- Path to the regular expressions to filter HTML titles.
htmlTitleRegPath :: FilePath
htmlTitleRegPath = "./regexps"

-- Send a message to IRC. That message should use the IRC protocol (RFC 1459).
--
-- That function embeds a naive protection again IRC commands injection.
toIRC :: String -> Session ()
toIRC msg = do
    liftIO . putStrLn $ "DEBUG: sending: '" ++ msg ++ "'"
    unless (null msg) $ asks conHandle >>= lift . flip hPutStrLn msg

-- Receive a line from IRC. The line is formatted using the IRC protocol (RFC 1459).
fromIRC :: Session String
fromIRC = asks conHandle >>= \h -> lift $ catch (hGetLine h) handleError
  where
    handleError :: SomeException -> IO String
    handleError _ = pure ""

-- Send a message to someone in the current IRC session.
msgIRC :: String -> String -> Session ()
msgIRC to msg = unless (null msg) . toIRC $ "PRIVMSG " ++ to ++ " :" ++ msg

-- Notice a message to someone in the current IRC session. Can be a channel as well if the
-- destination starts with a dash ('#').
noticeIRC :: String -> String -> Session ()
noticeIRC to msg = unless (null msg) . toIRC $ "NOTICE " ++ to ++ " :" ++ msg

-- Parse a list of arguments to retrieve information about the connection.
getConInfo :: [String] -> Either String (Server,Chan,String,String)
getConInfo args
  | length args == 4 = let [host,chan,nick,pwd] = args in Right (host,chan,nick,pwd)
  | otherwise = Left "expected server host, chan, nick and admin password"

main :: IO ()
main = do
  hSetBuffering stdout NoBuffering
  hSetBuffering stderr NoBuffering

  putStrLn . showVersion $ version
  args <- getArgs
  connectIRC args

-- Connect to the IRC server with the given arguments. On a failure, outputs to stderr.
connectIRC :: [String] -> IO ()
connectIRC args = do
    case getConInfo args of
      Right (host,chan,nick,pwd) -> go host chan nick pwd
      Left e -> errLn $ "unable to get connection information: " ++ e
  where
    go host chan nick pwd = do
      putStrLn $ "connecting to " ++ host
      eitherCon <- try $ do
        h <- connectTo host (PortNumber . fromIntegral $ ircPort)
        hSetBuffering h NoBuffering
        session (ConInfo host chan nick h pwd) $ do
          initIRC nick
          joinChan
          idle
      either (reconnect host chan nick pwd) (const $ pure ()) eitherCon
    reconnect host chan nick pwd e = do
      errLn $ "disconnected: " ++ show (e :: SomeException)
      threadDelay reconnectDelay
      go host chan nick pwd

-- Run a new session.
session :: ConInfo -> Session a -> IO a
session cinfo s = do
  (a,_,_) <- runRWST s cinfo M.empty
  return a

-- Initialize the IRC link.
initIRC :: String -> Session ()
initIRC nick = do
  toIRC "USER a b c :d"
  toIRC ("NICK " ++ nick)

-- Join the channel.
joinChan :: Session ()
joinChan = do
  chan <- asks conChan
  liftIO . putStrLn $ "joining " ++ chan
  toIRC ("JOIN " ++ chan)

-- Idle and wait for activity.
idle :: Session ()
idle = forever $ fromIRC >>= onIRCActivity . purgeContent

-- Purge the content of the message given by IRC by removing newlines.
purgeContent :: String -> String
purgeContent = filter $ \c -> not $ c `elem` "\n\r"

-- Reactive function called whenever a new IRC activity has been detected.
onIRCActivity :: String -> Session ()
onIRCActivity c = do
    liftIO (putStrLn c)
    dispatch
  where
    tailC = tail c
    dispatch
        | isMsg c   = treatMsg tailC
        | isKick c  = treatKick c
        | isPing c  = treatPing c
        | otherwise = pure ()

-- FIXME: those functions are not really safe and might be flaws
-- Is a message a ping?
isPing :: String -> Bool
isPing c = "PING" `elem` words c

-- Is a message a user message?
isMsg :: String -> Bool
isMsg c = "PRIVMSG" `elem` words c

-- Is a message a kick?
isKick :: String -> Bool
isKick c = "KICK" `elem` words c

-- Respond to ping.
treatPing :: String -> Session ()
treatPing ping = toIRC pong
  where
    pong = "PONG" ++ numericPing
    numericPing = snd . break (==' ') $ ping

-- When a user writes a message, we need to do several things.
--
-- In the first place, we want to filter out the case when we are the one to talk. In that case, we
-- just do nothing.
--
-- Then, if it’s someone else, we just try to tell them stories. Then, we look for a URL in their
-- message. If we’ve found a URL, we just try to extract its HTML title, and broadcast it on the
-- channel. If we don’t find a URL or we fail to parse the title, we just do nothing.
--
-- Finally, we check whether the message is not a command message. If so, we just branch on the
-- 'onCmd' function.
treatMsg :: String -> Session ()
treatMsg msg = do
    nick <- asks conNick
    chan <- asks conChan
    unless (null content || Nick emitter == Nick nick) $ do
      tellStories emitter
      let url = extractUrl content
      unless (null url) $ do
        title <- liftIO (htmlTitle htmlTitleRegPath url)
        traverse_ (\t -> msgIRC chan t) title
      when (head content == '!') $ do
        onCmd emitter recipient (tail content)
  where
    (emitter,recipient,content) = emitterRecipientContent msg

-- Extract the emitter, the recipient and the message.
emitterRecipientContent :: String -> (String,String,String)
emitterRecipientContent msg = (emitter,recipient,content)
  where
    (from':_:recipient:content') = splitOn " " msg
    emitter = fst . break (=='!') $ from'
    content = tailSafe (unwords content')

-- Extract the URL out of a message. If no URL is found, gives an empty string.
extractUrl :: String -> String
extractUrl = (=~ "https?://[^ ]+")

-- We might want to know whether we got kicked. If so, we just rejoin the server and insult the
-- person who has kicked us! 
treatKick :: String -> Session ()
treatKick msg = do
    nick <- asks conNick
    chan <- asks conChan
    when (kicked == nick) $ do
      joinChan
      msgIRC chan $ emitter ++ ": you sonavabitch."
  where
    (from',_,content) = emitterRecipientContent msg
    emitter = tailSafe from'
    kicked = tailSafe $ dropWhile (/=':') content

-- When someone tries to enter a command, we need to validate the command. First, the command is
-- looked up. If it doesn’t exist, nothing is performed, because it could be someone trying to
-- bruteforce us.
onCmd :: String -> String -> String -> Session ()
onCmd emitter recipient msg = traverse_ treatCmd (M.lookup cmd commands)
  where
    (cmd,arg) = second tailSafe . break (==' ') $ msg
    treatCmd c = c emitter recipient arg

-- List of available commands.
commands :: M.Map String (String -> String -> String -> Session ())
commands = M.fromList
    [
      ("tell",tellCmd)
    , ("do",doCmd)
    , ("help",helpCmd)
    ]

-- Function associated with the "tell" command.
tellCmd :: String -> String -> String -> Session ()
tellCmd emitter _ arg = do
    chan <- asks conChan
    treat chan
  where
    treat chan
        | length arg > 1 && not (null msg) = do
          nick <- fmap Nick (asks conNick)
          if emitterNick == nick
          then
            msgIRC chan "I'll tell myself for sure pal!"
          else do
            now <- liftIO $ utctDay `liftM` getCurrentTime
            modify . M.insertWith (flip (++)) (show emitterNick) $
              [show now ++ ", \STX\ETX6" ++ emitter ++ "\SI told \STX\ETX2" ++ (unNick emitterNick) ++ "\SI: " ++ msg]
            msgIRC emitter "\\_o<"
        | otherwise = msgIRC chan "nope!"
    (emitterNick,msg) = bimap Nick tailSafe . break (==' ') $ arg

-- Function associated with the "do" command. That command is used to perform several administration
-- tasks by making the bot *do* things for us.
doCmd :: String -> String -> String -> Session ()
doCmd emitter recipient arg = do
    chan   <- asks conChan
    myNick <- asks conNick
    pwd    <- asks conPwd
    treatDo chan myNick pwd
  where
    treatDo chan myNick pwd
        | recipient == chan = msgIRC emitter "I'm sorry, I feel naked in public ;)"
        | Nick recipient == Nick myNick && length args >= 3 = executeDo chan pwd
        | otherwise = msgIRC emitter "huhu, something went terribly wrong!"
    args = words arg
    userPwd:action:actionParams = args
    executeDo chan pwd
        | pwd /= userPwd = msgIRC emitter "wrong password!"
        | otherwise = executeAction chan
    executeAction chan
        | action == "op"     = traverse_ (toIRC . (mode chan "+o"++)) actionParams
        | action == "deop"   = traverse_ (toIRC . (mode chan "-o"++)) actionParams
        | action == "say"    = msgIRC chan (unwords actionParams)
        | action == "kick"   = traverse_ (toIRC . (("KICK " ++ chan ++ " ")++)) actionParams
        | action == "notice" = noticeIRC chan (unwords actionParams)
        | otherwise = msgIRC emitter "unknown action"
    mode chan m = "MODE " ++ chan ++ " " ++ m ++ " "

-- Display the help to the recipient.
helpCmd :: String -> String -> String -> Session ()
helpCmd recipient _ _ = do
  myNick <- asks conNick
  msgIRC recipient $ "!tell dest msg: leave a message to a beloved"
  msgIRC recipient $ "!do pwd action params: perform an action"
  msgIRC recipient $ "-   -   op user0 user1...: grant op privileges"
  msgIRC recipient $ "-   -   deop user0 user1...: revoke op privileges"
  msgIRC recipient $ "-   -   kick user0 user1...: kick them all!"
  msgIRC recipient $ "-   -   say blabla: make " ++ myNick ++ " say something"
  msgIRC recipient $ "-   -   notice msg: notice the channel something"
  msgIRC recipient . showVersion $ version
  msgIRC recipient $ "written in Haskell by phaazon"

-- Tell stories to someone.
tellStories :: String -> Session ()
tellStories recipient = do
    stories <- gets (toList . M.lookup (show $ Nick recipient))
    let
      cstories = concat stories
      chunks = map (mapM_ $ msgIRC recipient) . chunksOf floodThreshold $ cstories
      tells  = intersperse (liftIO $ threadDelay floodDelay) chunks
    unless (null stories) $ do
      sequence_ tells
      modify (M.delete . show $ Nick recipient)

errLn :: (MonadIO m) => String -> m ()
errLn = liftIO . hPutStrLn stderr

-- Safer tail.
tailSafe :: [a] -> [a]
tailSafe [] = []
tailSafe (_:xs) = xs