packages feed

hulk-0.2.0: src/Hulk/Server.hs

{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE RecordWildCards     #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Hulk.Server where

import           Hulk.Client                (handleCommand, makeCommand,
                                             outgoingWriter)
import           Hulk.Types

import           Control.Applicative
import           Control.Concurrent
import           Control.Concurrent.Delay
import           Control.Exception          (IOException, try)
import           Control.Monad
import           Control.Monad.Fix
import           Data.Aeson
import qualified Data.ByteString.Char8      as S8
import qualified Data.ByteString.Lazy       as L
import qualified Data.ByteString.Lazy.Char8 as L8
import           Data.CaseInsensitive
import           Data.Char
import qualified Data.Map                   as M
import           Data.Maybe
import           Data.Monoid
import           Data.Text                  (Text, pack, unpack)
import qualified Data.Text                  as T
import           Data.Text.Encoding         (encodeUtf8)
import qualified Data.Text.IO               as T
import           Data.Time
import           Network
import           Network.FastIRC            hiding (UserName)
import qualified Network.FastIRC.IO         as IRC
import           Prelude                    hiding (catch)
import           System.Directory
import           System.FilePath
import           System.IO

-- | Start an IRC server with the given configuration.
start :: Config -> IO ()
start config = withSocketsDo $ do
  hSetBuffering stdout LineBuffering
  listenSock <- listenOn $ PortNumber (configListen config)
  statevar <- newMVar HulkState { stateClients  = M.empty
                                , stateNicks    = M.empty
                                , stateChannels = M.empty }
  lvar <- newMVar ()
  forever $ do
    (handle,host,_port) <- accept listenSock
    hSetBuffering handle NoBuffering
    now <- getCurrentTime
    let conn = Conn { connRef = mkRef handle
                    , connHostname = pack host
                    , connServerName = configHostname config
                    , connTime = now
                    }
    auth <- getAuth config
    void $ forkIO $ handleClient lvar config handle statevar auth conn

-- | Handle a client connection.
handleClient :: MVar () -> Config -> Handle -> MVar HulkState -> (String,String) -> Conn -> IO ()
handleClient lvar config handle env auth conn = do
  messages <- newChan
  let writeMsg cmd =
        writeChan messages (Message Nothing (StringCmd cmd []))

  pinger <- forkIO $ forever $ do
    delayMinutes 2
    writeMsg "PINGPONG"

  void $ forkIO $ fix $ \loop -> do
    eline <- try (S8.hGetLine handle)
    case eline of
      Left (_::IOException) -> do killThread pinger
                                  writeMsg "DISCONNECT"
      Right line ->
        case readMessage line of
          Just msg -> do writeChan messages msg
                         loop
          Nothing -> loop

  fix $ \loop -> do
     msg <- readChan messages
     runClientHandler lvar config env handle conn auth msg
     case msg of
       Message  _  (StringCmd "DISCONNECT" _) -> return ()
       _ -> loop

-- | Handle a received message from the client.
runClientHandler :: MVar () -> Config -> MVar HulkState -> Handle -> Conn -> (String,String) -> Message -> IO ()
runClientHandler lvar config mstate handle conn auth msg = do
  now <- getCurrentTime
  instructions <- modifyMVar mstate $ \state -> return $
    let ((),newstate,instructions) = handleCommand config state now conn auth (msgCommand msg)
    in (newstate,instructions)
  forM_ instructions $ handleWriter lvar config handle

-- | Act on writer from the client.
handleWriter :: MVar () -> Config -> Handle -> HulkWriter -> IO ()
handleWriter lvar config@Config{..} handle writer = do
  case writer of
    SaveLog name rpl params -> saveToLog lvar config name rpl params
    MessageReply ref msg -> sendMessage ref msg
    LogReply line -> logLine line
    Close -> hClose handle
    Bump (Ref h) -> hClose h
    UpdateUserData udata -> do
      L.writeFile (configUserData </> normalizeUser (unpack (userText (userDataUser udata))))
                  (encode udata)
    SendEvents ref user -> do
      writers <- sendEvents config ref user
      mapM_ (handleWriter lvar config handle) (concat writers)

-- | Send a message to a client.
sendMessage :: Ref -> Message -> IO ()
sendMessage (Ref handle) msg =
  void $ (try $ IRC.hPutMessage handle msg :: IO (Either IOException ()))

-- | Add a line to the log file.
logLine :: Text -> IO ()
logLine = T.putStrLn

-- | Normalize the username for a filename.
normalizeUser :: [Char] -> [Char]
normalizeUser = filter (\c -> isDigit c || isLetter c)

-- | Send events that the user missed.
sendEvents :: Config -> Ref -> UserName -> IO [[HulkWriter]]
sendEvents config ref user = do
  events <- getLog config
  UserData{userDataLastSeen=lastSeen} <- getUser config (userText user)
  let filtered = flip filter events $ \(time,_from,_typ,_params) ->
                  time >. lastSeen
  forM filtered $ \msg -> do
    case msg of
      (time,from',rpl@RPL_PRIVMSG,[name,msg'])
        | name == userText user || "#" `T.isPrefixOf` name -> do
        let from = T.filter (\c -> isDigit c || isLetter c) from'
            user' = User (encodeUtf8 from)
                         (encodeUtf8 from)
                         "offline"
            message = Message (Just user')
                              (makeCommand rpl
                                           [name,"[" <> pack (show time) <> "] " <> msg'])
        return [MessageReply ref message
               ,outgoing message]
      _ -> return []

  where x >. y = x `diffUTCTime` y > 0
        outgoing msg = outgoingWriter ref msg

-- | Get the log.
getLog :: FromJSON b => Config -> IO [b]
getLog Config{..} = do
  contents <- L.readFile configLogFile
  return $ mapMaybe decode $ L8.lines contents

-- | Get the user data.
getUser :: Config -> Text -> IO UserData
getUser Config{..} name = do
  let fname = configUserData </> normalizeUser (unpack name)
  now <- getCurrentTime
  exists <- doesFileExist fname
  if exists
     then do contents <- L.readFile fname
             case decode contents of
               Just u -> return u
               Nothing -> error ("unable to parse user file: " ++ fname)
     else return $ UserData (UserName (mk name)) now

-- | Get authorization info.
getAuth :: Config -> IO (String,String)
getAuth Config{..} =
  (,) <$> readFile configPasswdKey
      <*> readFile configPasswd

-- | Save the message to the log.
saveToLog :: MVar () -> Config -> Text -> RPL -> [Text] -> IO ()
saveToLog lvar Config{..} name rpl params = do
  now <- getCurrentTime
  withMVar lvar $ const $
    L.appendFile configLogFile $
      encode ((now,name,rpl,params)) <> "\n"