/src/IrcBot.hs

http://github.com/Eelis/geordi · Haskell · 232 lines · 199 code · 25 blank · 8 comment · 37 complexity · ac25996962bd25b31e8947824dc71f62 MD5 · raw file

  1. {-# LANGUAGE UnicodeSyntax, PatternGuards, RecordWildCards #-}
  2. import qualified Network.Socket as Net
  3. import qualified System.Environment
  4. import qualified Request
  5. import qualified RequestEval
  6. import qualified Sys
  7. import qualified Data.Map as Map
  8. import qualified Network.BSD
  9. import qualified Cxx.Show
  10. import qualified IRC
  11. import qualified Data.ByteString
  12. import IRC (Command(..), Prefix(..))
  13. import Control.Exception (bracketOnError)
  14. import System.IO (hSetBinaryMode, hFlush, Handle, IOMode(..), stdout)
  15. import Control.Monad (forever, when)
  16. import Control.Arrow (first)
  17. import Control.Monad.State (execStateT, lift, StateT, get)
  18. import Control.Monad.Writer (execWriterT, tell)
  19. import System.Console.GetOpt (OptDescr(..), ArgDescr(..), ArgOrder(..), getOpt, usageInfo)
  20. import System.Locale.SetLocale (setLocale, Category(..))
  21. import Text.Regex (Regex, subRegex, mkRegexWithOpts) -- Todo: Text.Regex truncates Char's >256. Get rid of it.
  22. import Data.Char (isSpace, isPrint, isDigit)
  23. import Data.List (isSuffixOf, isPrefixOf)
  24. import Data.Map (Map)
  25. import Data.SetOps
  26. import Util ((.), elemBy, caselessStringEq, maybeM, describe_new_output,
  27. orElse, full_evaluate, withResource, mapState',
  28. strip_utf8_bom, none, takeBack, replaceInfix, classify_diagnostic)
  29. import Sys (rate_limiter)
  30. import Prelude hiding ((.))
  31. import Prelude.Unicode hiding (())
  32. data IrcBotConfig = IrcBotConfig
  33. { server :: Net.HostName, port :: Net.PortNumber
  34. , password :: Maybe String
  35. , max_response_length :: Int
  36. , chans :: [String], key_chans :: [(String, String)]
  37. , nick :: String, nick_pass :: Maybe String, alternate_nick :: String
  38. , also_respond_to :: [String]
  39. , allow_nickless_requests_in :: [String]
  40. , blacklist :: [String]
  41. , no_output_msg :: String
  42. , channel_response_prefix :: String
  43. -- A first occurrence of the string "nick" is replaced with the nick of the requester.
  44. , join_trigger :: Maybe IRC.Message
  45. -- Defaults to RPL_WELCOME. Can be set to NickServ/cloak confirmations and such.
  46. , censor :: [Regex]
  47. , rate_limit_messages, rate_limit_window :: Int
  48. , serve_private_requests :: Bool
  49. , clang_by_default :: Bool
  50. } deriving Read
  51. instance Read Regex where
  52. readsPrec i s = first (\r mkRegexWithOpts r True False) . readsPrec i s
  53. data Opt = Help deriving Eq
  54. optsDesc :: [OptDescr Opt]
  55. optsDesc = [Option "h" ["help"] (NoArg Help) "Display this help and exit."]
  56. help :: String
  57. help = usageInfo "Usage: sudo geordi-irc [option]...\nOptions:" optsDesc ++ "\nSee README.xhtml for more information."
  58. getArgs :: IO [Opt]
  59. getArgs = do
  60. args System.Environment.getArgs
  61. case getOpt RequireOrder optsDesc args of
  62. (_, _, err:_) fail $ init err
  63. (_, w:_, []) fail $ "superfluous command line argument: " ++ w
  64. (opts, [], []) return opts
  65. do_censor :: IrcBotConfig String String
  66. do_censor cfg s = foldr (\r t subRegex r t "<censored>") s (censor cfg)
  67. main :: IO ()
  68. main = do
  69. setLocale LC_ALL (Just "")
  70. opts getArgs
  71. if Help opts then putStrLn help else do
  72. cfg@IrcBotConfig{..} read . getContents
  73. full_evaluate $ do_censor cfg "abc" -- So that any mkRegex failures occur before we start connecting.
  74. putStrLn $ "Connecting to " ++ server ++ ":" ++ show port
  75. withResource (connect server (fromIntegral port)) $ \h do
  76. putStrLn "Connected"
  77. evalRequest RequestEval.evaluator
  78. limit_rate rate_limiter rate_limit_messages rate_limit_window
  79. let send m = limit_rate >> IRC.send h (IRC.Message Nothing m)
  80. maybeM password $ send . Pass
  81. send $ Nick nick
  82. send $ User nick 0 nick
  83. flip execStateT () $ forever $ do
  84. l lift $ Data.ByteString.hGetLine h
  85. case IRC.decode l of
  86. Nothing lift $ putStrLn "Malformed IRC message."
  87. Just m do
  88. lift $ print m
  89. r on_msg evalRequest cfg (Data.ByteString.length l == 511) m
  90. lift $ mapM_ print r >> hFlush stdout >> mapM_ send r
  91. return ()
  92. discarded_lines_description :: Int String
  93. discarded_lines_description s =
  94. " [+ " ++ show s ++ " discarded line" ++ (if s == 1 then "" else "s") ++ "]"
  95. describe_lines :: [String] String
  96. describe_lines [] = ""
  97. describe_lines (x:xs)
  98. | xs == [] || classify_diagnostic x == Just "error" = x
  99. | otherwise = x ++ discarded_lines_description (length xs)
  100. data ChannelMemory = ChannelMemory
  101. { context :: Request.Context
  102. , last_outputs :: [String]
  103. , last_nonrequest :: String }
  104. type ChannelMemoryMap = Map String ChannelMemory
  105. emptyChannelMemory :: IrcBotConfig ChannelMemory
  106. emptyChannelMemory IrcBotConfig{..} = ChannelMemory
  107. { context = Request.Context Cxx.Show.noHighlighting clang_by_default []
  108. , last_outputs = []
  109. , last_nonrequest = "" }
  110. is_request :: IrcBotConfig Where String Maybe String
  111. is_request IrcBotConfig{..} _ s
  112. | Just (n, r) Request.is_addressed_request s
  113. , any (caselessStringEq n) (nick : alternate_nick : also_respond_to)
  114. = Just r
  115. is_request IrcBotConfig{..} (InChannel c) s
  116. | elemBy caselessStringEq c allow_nickless_requests_in
  117. , Just r Request.is_nickless_request s
  118. = Just r
  119. is_request _ Private s = Just s
  120. is_request _ _ _ = Nothing
  121. type Reason = String
  122. data Permission = Allow | Deny (Maybe Reason)
  123. data Where = Private | InChannel String
  124. request_allowed :: IrcBotConfig String Maybe IRC.UserName Maybe IRC.ServerName Where Permission
  125. request_allowed cfg _ _ _ Private | not (serve_private_requests cfg) =
  126. Deny $ Just "This bot does not serve private requests."
  127. request_allowed cfg nickname _ _ _ | nickname blacklist cfg = Deny Nothing
  128. request_allowed _ _ _ _ _ = Allow
  129. type Eraser = String Maybe String
  130. digits :: Eraser
  131. digits (x : y : s) | isDigit x, isDigit y = Just s
  132. digits (x : s) | isDigit x = Just s
  133. digits _ = Nothing
  134. color_code :: Eraser
  135. color_code ('\x3' : ',' : s) = digits s
  136. color_code ('\x3' : s) = case digits s of
  137. Just (',' : s') → digits s'
  138. Just s' → Just s'
  139. Nothing Just s
  140. color_code _ = Nothing
  141. apply_eraser :: Eraser String String
  142. apply_eraser _ [] = []
  143. apply_eraser p s@(h:t) = p s `orElse` (h : apply_eraser p t)
  144. strip_color_codes :: String String
  145. strip_color_codes = apply_eraser color_code
  146. {- Todo: The above is *much* more naturally expressed as:
  147. subRegex r s "" where r = mkRegex "\x3(,[[:digit:]]{1,2}|[[:digit:]]{1,2}(,[[:digit:]]{1,2})?)?"
  148. Unfortunately, Text.Regex is broken: it truncates Char's, resulting in spurious matches. -}
  149. version_response :: String
  150. version_response = "Geordi C++ bot - http://www.eelis.net/geordi/"
  151. on_msg :: (Functor m, Monad m)
  152. (String Request.Context [(String, String)] m Request.Response) IrcBotConfig Bool IRC.Message StateT ChannelMemoryMap m [IRC.Command]
  153. on_msg eval cfg@IrcBotConfig{..} full_size m@(IRC.Message prefix c) = execWriterT $ do
  154. when (join_trigger == Just m) join
  155. case c of
  156. Quit | Just (NickName n _ _) prefix, n == nick send $ Nick nick
  157. PrivMsg _ "\1VERSION\1" | Just (NickName n _ _) prefix
  158. send $ Notice n $ "\1VERSION " ++ version_response ++ "\1"
  159. NickNameInUse send $ Nick alternate_nick
  160. Ping x send $ Pong x
  161. PrivMsg _ ('\1':_) return ()
  162. PrivMsg to txt' | Just (NickName who muser mserver) ← prefix → do
  163. let
  164. txt = filter isPrint $ strip_color_codes $ strip_utf8_bom txt'
  165. private = elemBy caselessStringEq to [nick, alternate_nick]
  166. w = if private then Private else InChannel to
  167. wher = if private then who else to
  168. reply s = send $ PrivMsg wher $ take max_response_length $
  169. (if private then id else (replaceInfix "nick" who channel_response_prefix ++)) $
  170. if null s then no_output_msg else do_censor cfg s
  171. mem@ChannelMemory{..} (`orElse` emptyChannelMemory cfg) . Map.lookup wher . lift get
  172. case (dropWhile isSpace . is_request cfg w txt) of
  173. Nothing lift $ mapState' $ insert (wher, mem{last_nonrequest = txt'})
  174. Just r' → case request_allowed cfg who muser mserver w of
  175. Deny reason maybeM reason reply
  176. Allow do
  177. let r = if r' == "^" then last_nonrequest else r'
  178. if full_size none (`isSuffixOf` r) ["}", ";"] then reply $ "Request likely truncated after `" ++ takeBack 15 r ++ "`." else do
  179. -- The "}"/";" test gains a reduction in false positives at the cost of an increase in false negatives.
  180. let extra_env = [("GEORDI_REQUESTER", who), ("GEORDI_WHERE", wher)]
  181. Request.Response history_modification output lift $ lift $ eval r context extra_env
  182. let output' = describe_lines $ dropWhile null $ lines output
  183. let lo = take 50 last_outputs
  184. lift $ mapState' $ insert (wher, mem
  185. { context = maybe id Request.modify_history history_modification context
  186. , last_outputs = output' : lo })
  187. reply $ describe_new_output lo output'
  188. Welcome do
  189. maybeM nick_pass $ send . PrivMsg "NickServ" . ("identify " ++)
  190. when (join_trigger == Nothing) join
  191. Invite _ _ join
  192. _ return ()
  193. where
  194. send = tell . (:[])
  195. join = send $ Join chans key_chans
  196. connect :: Net.HostName Net.PortNumber IO Handle
  197. -- Mostly copied from Network.connectTo. We can't use that one because we want to set SO_KEEPALIVE (and related) options on the socket, which can't be done on a Handle.
  198. connect host portn = do
  199. proto Network.BSD.getProtocolNumber "tcp"
  200. let hints = Net.defaultHints { Net.addrSocketType = Net.Stream, Net.addrProtocol = proto }
  201. target head . Net.getAddrInfo (Just hints) (Just host) (Just $ show portn)
  202. bracketOnError (Net.socket (Net.addrFamily target) Net.Stream proto) Net.close $ \sock do
  203. Sys.setKeepAlive sock 30 10 5
  204. Net.connect sock (Net.addrAddress target)
  205. h Net.socketToHandle sock ReadWriteMode
  206. hSetBinaryMode h True
  207. return h