hulk-0.1.1: src/Hulk/Client.hs
{-# LANGUAGE RecordWildCards, ViewPatterns #-}
{-# OPTIONS -Wall -fno-warn-name-shadowing #-}
module Hulk.Client
(handleLine)
where
import Control.Applicative
import Control.Monad.Reader
import Control.Monad.State
import Control.Monad.Writer
import Data.Char
import Data.List.Split
import Data.Map (Map)
import qualified Data.Map as M
import Data.Maybe
import Data.Time
import Network.IRC hiding (Channel)
import Prelude hiding (log)
import Hulk.Auth
import Hulk.Event
import Hulk.Types
-- Entry point handler
-- | Handle an incoming line, try to parse it and handle the message.
handleLine :: MonadProvider m
=> Env -> UTCTime -> Conn -> String
-> m ([Reply],Env)
handleLine env now conn line = runClient env now conn $
case decode line of
Just Message{..} -> handleMsg line (readEventType msg_command,msg_params)
Nothing -> errorReply $ "Unable to parse " ++ show line
-- | Run the client monad.
runClient :: MonadProvider m
=> Env -> UTCTime -> Conn -> IRC m ()
-> m ([Reply],Env)
runClient env now conn m = do
flip runStateT env $
execWriterT $
flip runReaderT (now,conn) $
runIRC m
-- | Handle an incoming message.
handleMsg :: MonadProvider m => String -> (Event,[String]) -> IRC m ()
handleMsg line msg = do
case msg of
(NOTHING,_) -> invalidMessage line
(PASS,[pass]) -> asUnregistered $ handlePass pass
(PINGPONG,_) -> do incoming line; handlePingPong
safeToLog -> handleMsgSafeToLog line safeToLog
-- | Handle messages that are safe to log.
handleMsgSafeToLog :: MonadProvider m => String -> (Event,[String])
-> IRC m ()
handleMsgSafeToLog line safeToLog = do
incoming line
updateLastPong
case safeToLog of
(PONG,_) -> return ()
(USER,[user,_,_,realname]) -> asUnregistered $ handleUser user realname
(NICK,[nick]) -> handleNick nick
(PING,[param]) -> handlePing param
(QUIT,[msg]) -> handleQuit RequestedQuit msg
(TELL,[to,msg]) -> handleTell to msg
(DISCONNECT,[msg]) -> handleQuit SocketQuit msg
(CONNECT,_) -> handleConnect
mustBeReg'd -> handleMsgReg'd line mustBeReg'd
-- | Handle messages that can only be used when registered.
handleMsgReg'd :: Monad m => String -> (Event,[String]) -> IRC m ()
handleMsgReg'd line mustBeReg'd =
asRegistered $
case mustBeReg'd of
(JOIN,(name:_)) -> handleJoin name
(PART,[chan,msg]) -> handlePart chan msg
(PRIVMSG,[to,msg]) -> handlePrivmsg to msg
(TOPIC,[chan,topic]) -> handleTopic chan topic
(NOTICE,[to,msg]) -> handleNotice to msg
(WHOIS,(nick:_)) -> handleWhoIs nick
(ISON,people) -> handleIsOn people
_ -> invalidMessage line
-- | Log an invalid message.
invalidMessage :: Monad m => String -> IRC m ()
invalidMessage line = do
incoming line
errorReply $ "Invalid or unknown message type, or not" ++
" enough parameters: " ++ show line
-- Message handlers
-- | Handle the PINGPONG event. Disconnect the client if timedout.
handlePingPong :: Monad m => IRC m ()
handlePingPong = do
lastPong <- clientLastPong <$> getClient
now <- asks fst
let n = diffUTCTime now lastPong
if n > 60*4
then handleQuit RequestedQuit $ "Ping timeout: " ++ show n ++ " seconds"
else do hostname <- asks $ connServerName . snd
thisCmdReply RPL_PING [hostname]
-- | Handle the CONNECT event.
handleConnect :: MonadProvider m => IRC m ()
handleConnect = do
motd <- fmap lines <$> lift providePreface
case motd of
Nothing -> return ()
Just lines -> mapM_ notice lines
-- | Handle the PASS message.
handlePass :: MonadProvider m => String -> IRC m ()
handlePass pass = do
modifyUnregistered $ \u -> u { unregUserPass = Just pass }
notice "Received password."
tryRegister
-- | Handle the USER message.
handleUser :: MonadProvider m => String -> String -> IRC m ()
handleUser user realname = do
withSentPass $
if validUser user
then do modifyUnregistered $ \u -> u { unregUserUser = Just user
, unregUserName = Just realname }
notice "Recieved user details."
tryRegister
else errorReply "Invalid user format."
-- | Handle the USER message.
handleNick :: MonadProvider m => String -> IRC m ()
handleNick nick =
withSentPass $
withValidNick nick $ \nick ->
ifUniqueNick nick $ do
ref <- getRef
withRegistered $ \RegUser{regUserNick=nick} ->
modifyNicks $ M.delete nick
withUnegistered $ \UnregUser{unregUserNick=nick} ->
maybe (return ()) (modifyNicks . M.delete) nick
modifyNicks $ M.insert nick ref
modifyUnregistered $ \u -> u { unregUserNick = Just nick }
tryRegister
asRegistered $ do
thisClientReply RPL_NICK [unNick nick]
(myChannels >>=) $ mapM_ $ \Channel{..} -> do
channelReply channelName RPL_NICK [unNick nick] ExcludeMe
modifyRegistered $ \u -> u { regUserNick = nick }
-- | Handle the PING message.
handlePing :: Monad m => String -> IRC m ()
handlePing p = do
hostname <- asks $ connServerName . snd
thisServerReply RPL_PONG [hostname,p]
-- | Handle the QUIT message.
handleQuit :: Monad m => QuitType -> String -> IRC m ()
handleQuit quitType msg = do
(myChannels >>=) $ mapM_ $ \Channel{..} -> do
channelReply channelName RPL_QUIT [msg] ExcludeMe
removeFromChan channelName
withRegistered $ \RegUser{regUserNick=nick} -> do
modifyNicks $ M.delete nick
withUnegistered $ \UnregUser{unregUserNick=nick} -> do
maybe (return ()) (modifyNicks . M.delete) nick
ref <- getRef
modifyClients $ M.delete ref
notice msg
when (quitType == RequestedQuit) $ tell [Close]
-- | Handle the TELL message.
handleTell :: Monad m => String -> String -> IRC m ()
handleTell name msg = sendMsgTo RPL_NOTICE name msg
-- | Handle the JOIN message.
handleJoin :: Monad m => String -> IRC m ()
handleJoin chans = do
let names = splitWhen (==',') chans
forM_ names $ flip withValidChanName $ \name -> do
exists <- M.member name <$> gets envChannels
unless exists $ insertChannel name
joined <- inChannel name
unless joined $ joinChannel name
-- | Handle the PART message.
handlePart :: Monad m => String -> String -> IRC m ()
handlePart name msg =
withValidChanName name $ \name -> do
removeFromChan name
channelReply name RPL_PART [msg] IncludeMe
-- | Remove a user from a channel.
removeFromChan :: Monad m => ChannelName -> IRC m ()
removeFromChan name = do
ref <- getRef
let remMe c = c { channelUsers = filter (/=ref) (channelUsers c) }
modifyChannels $ M.adjust remMe name
-- | Handle the TOPIC message.
handleTopic :: Monad m => String -> String -> IRC m ()
handleTopic name topic =
withValidChanName name $ \name -> do
let setTopic c = c { channelTopic = Just topic }
modifyChannels $ M.adjust setTopic name
channelReply name RPL_TOPIC [unChanName name,topic] IncludeMe
-- | Handle the PRIVMSG message.
handlePrivmsg :: Monad m => String -> String -> IRC m ()
handlePrivmsg name msg = sendMsgTo RPL_PRIVMSG name msg
-- | Handle the NOTICE message.
handleNotice :: Monad m => String -> String -> IRC m ()
handleNotice name msg = sendMsgTo RPL_NOTICE name msg
-- | Handle WHOIS message.
handleWhoIs :: Monad m => String -> IRC m ()
handleWhoIs nick =
withValidNick nick $ \nick ->
withClientByNick nick $ \Client{..} ->
withRegUserByNick nick $ \RegUser{..} -> do
thisNickServerReply RPL_WHOISUSER
[unNick regUserNick
,regUserUser
,clientHostname
,"*"
,regUserName]
thisNickServerReply RPL_ENDOFWHOIS
[unNick regUserNick
,"End of WHOIS list."]
-- | Handle the ISON ('is on?') message.
handleIsOn :: Monad m => [String] -> IRC m ()
handleIsOn (catMaybes . map readNick -> nicks) =
asRegistered $ do
online <- catMaybes <$> mapM regUserByNick nicks
let nicks = unwords $ map (unNick.regUserNick) online
unless (null nicks) $ thisNickServerReply RPL_ISON [nicks ++ " "]
-- Generic message functions
-- | Send a message to a user or a channel (it figures it out).
sendMsgTo :: Monad m => RPL -> String -> String -> IRC m ()
sendMsgTo typ name msg =
if validChannel name
then withValidChanName name $ \name ->
channelReply name typ [unChanName name,msg] ExcludeMe
else userReply name typ [name,msg]
-- Channel functions
-- | Get channels that the current client is in.
myChannels :: Monad m => IRC m [Channel]
myChannels = do
ref <- getRef
filter (elem ref . channelUsers) . map snd . M.toList <$> gets envChannels
-- | Join a channel.
joinChannel :: Monad m => ChannelName -> IRC m ()
joinChannel name = do
ref <- getRef
let addMe c = c { channelUsers = channelUsers c ++ [ref] }
modifyChannels $ M.adjust addMe name
channelReply name RPL_JOIN [unChanName name] IncludeMe
sendNamesList name
withChannel name $ \Channel{..} -> do
case channelTopic of
Just topic -> thisServerReply RPL_TOPIC [unChanName name,topic]
Nothing -> return ()
-- | Send the names list of a channel.
sendNamesList :: Monad m => ChannelName -> IRC m ()
sendNamesList name = do
asRegistered $
withChannel name $ \Channel{..} -> do
clients <- catMaybes <$> mapM getClientByRef channelUsers
let nicks = map regUserNick . catMaybes . map clientRegUser $ clients
forM_ (splitEvery 10 nicks) $ \nicks ->
thisNickServerReply RPL_NAMEREPLY ["@",unChanName name
,unwords $ map unNick nicks]
thisNickServerReply RPL_ENDOFNAMES [unChanName name
,"End of /NAMES list."]
-- | Am I in a channel?
inChannel :: Monad m => ChannelName -> IRC m Bool
inChannel name = do
chan <- M.lookup name <$> gets envChannels
case chan of
Nothing -> return False
Just Channel{..} -> (`elem` channelUsers) <$> getRef
-- | Insert a new channel.
insertChannel :: Monad m => ChannelName -> IRC m ()
insertChannel name = modifyChannels $ M.insert name newChan where
newChan = Channel { channelName = name
, channelTopic = Nothing
, channelUsers = []
}
-- | Modify the channel map.
modifyChannels :: Monad m
=> (Map ChannelName Channel -> Map ChannelName Channel)
-> IRC m ()
modifyChannels f = modify $ \e -> e { envChannels = f (envChannels e) }
-- | Perform an action with an existing channel, sends error if not exists.
withChannel :: Monad m => ChannelName -> (Channel -> IRC m ()) -> IRC m ()
withChannel name m = do
chan <- M.lookup name <$> gets envChannels
case chan of
Nothing -> thisServerReply ERR_NOSUCHCHANNEL [unChanName name
,"No such channel."]
Just chan -> m chan
withValidChanName :: Monad m => String -> (ChannelName -> IRC m ())
-> IRC m ()
withValidChanName name m
| validChannel name = m $ ChannelName name
| otherwise = errorReply $ "Invalid channel name: " ++ name
-- Client/user access functions
-- | Update the last pong reply time.
updateLastPong :: Monad m => IRC m ()
updateLastPong = do
ref <- getRef
now <- asks fst
let adjust client@Client{..} = client { clientLastPong = now }
modifyClients $ M.adjust adjust ref
-- | Read a valid nick.
readNick :: String -> Maybe Nick
readNick n | validNick n = Just $ Nick n
| otherwise = Nothing
-- | Modify the nicks mapping.
modifyNicks :: Monad m => (Map Nick Ref -> Map Nick Ref) -> IRC m ()
modifyNicks f = modify $ \env -> env { envNicks = f (envNicks env) }
-- | With a valid nickname, perform an action.
withValidNick :: Monad m => String -> (Nick -> IRC m ()) -> IRC m ()
withValidNick nick m
| validNick nick = m (Nick nick)
| otherwise = errorReply $ "Invalid nick format: " ++ nick
-- | Perform an action if a nickname is unique, otherwise send error.
ifUniqueNick :: Monad m => Nick -> IRC m () -> IRC m ()
ifUniqueNick nick m = do
clients <- gets envClients
client <- (M.lookup nick >=> (`M.lookup` clients)) <$> gets envNicks
case client of
Nothing -> m
Just{} -> thisServerReply ERR_NICKNAMEINUSE
[unNick nick,"Nick is already in use."]
-- | Try to register the user with the USER/NICK/PASS that have been given.
tryRegister :: MonadProvider m => IRC m ()
tryRegister =
withUnegistered $ \UnregUser{..} -> do
let details = (,,,) <$> unregUserName
<*> unregUserNick
<*> unregUserUser
<*> unregUserPass
case details of
Nothing -> return ()
Just (name,nick,user,pass) -> do
authentic <- lift $ authenticate user pass
if not authentic
then errorReply $ "Wrong user/pass."
else do modifyUser $ \_ ->
Registered $ RegUser name nick user pass
sendWelcome
sendMotd
-- | Send a client reply to a user.
userReply :: Monad m => String -> RPL -> [String] -> IRC m ()
userReply nick typ ps =
withValidNick nick $ \nick ->
withClientByNick nick $ \Client{..} ->
clientReply clientRef typ ps
-- | Perform an action with a client by nickname.
withClientByNick :: Monad m => Nick -> (Client -> IRC m ()) -> IRC m ()
withClientByNick nick m = do
client <- clientByNick nick
case client of
Nothing -> sendNoSuchNick nick
Just client@Client{..}
| isRegistered clientUser -> m client
| otherwise -> sendNoSuchNick nick
-- | Perform an action with a registered user by its nickname.
withRegUserByNick :: Monad m => Nick -> (RegUser -> IRC m ()) -> IRC m ()
withRegUserByNick nick m = do
user <- regUserByNick nick
case user of
Just user -> m user
Nothing -> sendNoSuchNick nick
-- | Send the RPL_NOSUCHNICK reply.
sendNoSuchNick :: Monad m => Nick -> IRC m ()
sendNoSuchNick nick =
thisServerReply ERR_NOSUCHNICK [unNick nick,"No such nick."]
-- | Get a registered user by nickname.
regUserByNick :: Monad m => Nick -> IRC m (Maybe RegUser)
regUserByNick nick = do
c <- clientByNick nick
case clientUser <$> c of
Just (Registered u) -> return $ Just u
_ -> return Nothing
-- | Get a client by nickname.
clientByNick :: Monad m => Nick -> IRC m (Maybe Client)
clientByNick nick = do
clients <- gets envClients
(M.lookup nick >=> (`M.lookup` clients)) <$> gets envNicks
-- | Maybe get a registered user from a client.
clientRegUser :: Client -> Maybe RegUser
clientRegUser Client{..} =
case clientUser of
Registered u -> Just u
_ -> Nothing
-- | Modify the current user if unregistered.
modifyUnregistered :: Monad m => (UnregUser -> UnregUser) -> IRC m ()
modifyUnregistered f = do
modifyUser $ \user ->
case user of
Unregistered user -> Unregistered (f user)
u -> u
-- | Modify the current user if registered.
modifyRegistered :: Monad m => (RegUser -> RegUser) -> IRC m ()
modifyRegistered f = do
modifyUser $ \user ->
case user of
Registered user -> Registered (f user)
u -> u
-- | Modify the current user.
modifyUser :: Monad m => (User -> User) -> IRC m ()
modifyUser f = do
ref <- getRef
let modUser c = c { clientUser = f (clientUser c) }
modClient = M.adjust modUser ref
modify $ \env -> env { envClients = modClient (envClients env) }
-- | Only perform command if the client is registered.
asRegistered :: Monad m => IRC m () -> IRC m ()
asRegistered m = do
registered <- isRegistered <$> getUser
when registered m
-- | Perform command with a registered user.
withRegistered :: Monad m => (RegUser -> IRC m ()) -> IRC m ()
withRegistered m = do
user <- getUser
case user of
Registered user -> m user
_ -> return ()
-- | With sent pass.
withSentPass :: Monad m => IRC m () -> IRC m ()
withSentPass m = do
asRegistered m
withUnegistered $ \UnregUser{..} -> do
case unregUserPass of
Just{} -> m
Nothing -> return ()
-- | Perform command with a registered user.
withUnegistered :: Monad m => (UnregUser -> IRC m ()) -> IRC m ()
withUnegistered m = do
user <- getUser
case user of
Unregistered user -> m user
_ -> return ()
-- | Only perform command if the client is registered.
asUnregistered :: Monad m => IRC m () -> IRC m ()
asUnregistered m = do
registered <- isRegistered <$> getUser
unless registered m
-- | Is a user registered?
isRegistered :: User -> Bool
isRegistered Registered{} = True
isRegistered _ = False
-- | Get the current client's user.
getUser :: Monad m => IRC m User
getUser = clientUser <$> getClient
-- | Get the current client.
getClientByRef :: Monad m => Ref -> IRC m (Maybe Client)
getClientByRef ref = do
clients <- gets envClients
return $ M.lookup ref clients
-- | Get the current client.
getClient :: Monad m => IRC m Client
getClient = do
ref <- getRef
clients <- gets envClients
case M.lookup ref clients of
Just client -> return $ client
Nothing -> makeNewClient
-- | Modify the clients table.
modifyClients :: Monad m => (Map Ref Client -> Map Ref Client) -> IRC m ()
modifyClients f = modify $ \env -> env { envClients = f (envClients env) }
-- | Make a current client based on the current connection.
makeNewClient :: Monad m => IRC m Client
makeNewClient = do
Conn{..} <- asks snd
let client = Client { clientRef = connRef
, clientHostname = connHostname
, clientUser = newUnregisteredUser
, clientLastPong = connTime }
modifyClients $ M.insert connRef client
return client
-- | Make a new unregistered user.
newUnregisteredUser :: User
newUnregisteredUser = Unregistered $ UnregUser {
unregUserName = Nothing
,unregUserNick = Nothing
,unregUserUser = Nothing
,unregUserPass = Nothing
}
-- Channel replies
-- | Send a client reply to everyone in a channel.
channelReply :: Monad m => ChannelName -> RPL -> [String]
-> ChannelReplyType
-> IRC m ()
channelReply name cmd params typ = do
withChannel name $ \Channel{..} -> do
ref <- getRef
forM_ channelUsers $ \theirRef -> do
unless (typ == ExcludeMe && ref == theirRef) $
clientReply theirRef cmd params
-- Client replies
-- | Send a client reply to the current client.
thisClientReply :: Monad m => RPL -> [String] -> IRC m ()
thisClientReply typ params = do
ref <- getRef
clientReply ref typ params
-- | Send a client reply of the given type with the given params, on
-- the given connection reference.
clientReply :: Monad m => Ref -> RPL -> [String] -> IRC m ()
clientReply ref typ params = do
withRegistered $ \user -> do
client <- getClient
msg <- newClientMsg client user typ params
reply ref msg
-- | Make a new IRC message from the current client.
newClientMsg :: Monad m => Client -> RegUser -> RPL -> [String]
-> IRC m Message
newClientMsg Client{..} RegUser{..} cmd ps = do
let nickName = NickName (unNick regUserNick)
(Just regUserUser)
(Just clientHostname)
return $ Message {
msg_prefix = Just $ nickName
,msg_command = fromRPL cmd
,msg_params = ps
}
-- Server replies
-- | Send the welcome message.
sendWelcome :: Monad m => IRC m ()
sendWelcome = do
withRegistered $ \RegUser{..} -> do
thisNickServerReply RPL_WELCOME ["Welcome."]
-- | Send the MOTD.
sendMotd :: MonadProvider m => IRC m ()
sendMotd = do
asRegistered $ do
thisNickServerReply RPL_MOTDSTART ["MOTD"]
motd <- fmap lines <$> lift provideMotd
let motdLine line = thisNickServerReply RPL_MOTD [line]
case motd of
Nothing -> motdLine "None."
Just lines -> mapM_ motdLine lines
thisNickServerReply RPL_ENDOFMOTD ["/MOTD."]
-- | Send a message reply.
notice :: Monad m => String -> IRC m ()
notice msg = thisServerReply RPL_NOTICE ["*",msg]
thisNickServerReply :: Monad m => RPL -> [String] -> IRC m ()
thisNickServerReply typ params = do
withRegistered $ \RegUser{regUserNick=Nick nick} ->
thisServerReply typ (nick : params)
-- | Send a server reply of the given type with the given params.
thisServerReply :: Monad m => RPL -> [String] -> IRC m ()
thisServerReply typ params = do
ref <- getRef
serverReply ref typ params
-- | Send a server reply of the given type with the given params.
serverReply :: Monad m => Ref -> RPL -> [String] -> IRC m ()
serverReply ref typ params = do
msg <- newServerMsg typ params
reply ref msg
-- | Make a new IRC message from the server.
newServerMsg :: Monad m => RPL -> [String] -> IRC m Message
newServerMsg cmd ps = do
hostname <- asks $ connServerName . snd
return $ Message {
msg_prefix = Just $ Server hostname
,msg_command = fromRPL cmd
,msg_params = ps
}
-- | Send a cmd reply of the given type with the given params.
thisCmdReply :: Monad m => RPL -> [String] -> IRC m ()
thisCmdReply typ params = do
ref <- getRef
cmdReply ref typ params
-- | Send a cmd reply of the given type with the given params.
cmdReply :: Monad m => Ref -> RPL -> [String] -> IRC m ()
cmdReply ref typ params = do
msg <- newCmdMsg typ params
reply ref msg
-- | Make a new IRC message from the cmd.
newCmdMsg :: Monad m => RPL -> [String] -> IRC m Message
newCmdMsg cmd ps = do
return $ Message {
msg_prefix = Nothing
,msg_command = fromRPL cmd
,msg_params = ps
}
-- | Get the current connection ref.
getRef :: Monad m => IRC m Ref
getRef = asks $ connRef . snd
-- Output functions
-- | Send an error reply.
errorReply :: Monad m => String -> IRC m ()
errorReply m = do
notice $ "ERROR: " ++ m
log $ "ERROR: " ++ m
-- | Send a message reply.
reply :: Monad m => Ref -> Message -> IRC m ()
reply ref msg = do
outgoing $ encode msg
tell . return $ MessageReply ref msg
-- | Log an incoming line.
incoming :: Monad m => String -> IRC m ()
incoming = log . ("<- " ++)
-- | Log an outgoing line.
outgoing :: Monad m => String -> IRC m ()
outgoing = log . ("-> " ++)
-- | Log a line.
log :: Monad m => String -> IRC m ()
log line = do
ref <- getRef
tell . return . LogReply $ show (unRef ref) ++ ": " ++ line
-- Validation functions
-- | Is a username valid?
validUser :: String -> Bool
validUser = validNick
-- | Is a nickname valid? Digit/letter or one of these: -_/\\;()[]{}?`'
validNick :: String -> Bool
validNick s = all ok s && length s > 0 where
ok c = isDigit c || isLetter c || elem c "-_/\\;()[]{}?`'"
-- | Valid channel name?
validChannel :: String -> Bool
validChannel ('#':cs) = all ok cs && length cs > 0 where
ok c = isDigit c || isLetter c || elem c "-_/\\;()[]{}?`'"
validChannel _ = False