packages feed

lambdabot-zulip-0.1.0: src/Web/Zulip/Lambdabot.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternGuards #-}

-- | A lambdabot for the Zulip web chat.
module Web.Zulip.Lambdabot where

import           Control.Monad (when)
import           Control.Monad.IO.Class
import           Data.Char (isDigit)
import           Data.List (stripPrefix)
import           Data.Monoid ((<>))
import qualified Data.Set as Set
import           Data.Text (Text)
import qualified Data.Text as T
import           Data.Yaml.Aeson (FromJSON, ToJSON)
import           Data.Yaml.Config
import           GHC.Generics
import           Language.Haskell.Interpreter (runInterpreter, InterpreterError(..), GhcError(errMsg))
import           Mueval.ArgsParse (typeOnly, interpreterOpts)
import           Mueval.Interpreter (interpreter)
import qualified Options.Applicative as Opts
import           Say (say)
import           Text.ParserCombinators.ReadP (ReadP, readP_to_S, char, munch1, skipSpaces)
import           Web.HZulip
import           System.IO (hSetBuffering, stdout, BufferMode(LineBuffering))


-- Adapted from https://hackage.haskell.org/package/mueval-0.9.3/docs/src/Mueval-Interpreter.html#printInterpreterError

-- | Removes uninteresting parts of error messages.
-- Example:
-- > dropErrorLinePosition "<interactive>:1:1: error: Variable not in scope: help"
-- is
-- > "error: Variable not in scope: help"
dropErrorLinePosition :: String -> String
dropErrorLinePosition e =
  case parseReturnRest interactiveErrorPrefixParser e of
    Just rest -> rest
    Nothing -> e -- if the parse fails we fallback on printing the whole error
  where
    interactiveErrorPrefixParser :: ReadP ()
    interactiveErrorPrefixParser = do
      pure ()
      <* char '<' <* munch1 (/= '>') <* char '>' <* char ':' -- e.g. <interactive>:
      <* munch1 isDigit <* char ':' -- line
      <* munch1 isDigit <* char ':' -- column
      <* skipSpaces

    parseReturnRest :: ReadP () -> String -> Maybe String
    parseReturnRest parser input =
      case readP_to_S parser input of
        [] -> Nothing
        (_dropped, rest):_ -> Just rest


-- | Turns a @hint@ `InterpreterError` into our simple error type.
formatInterpreterError :: InterpreterError -> EvalErrors
formatInterpreterError err = case err of
  WontCompile errors -> EvalErrors $ map (dropErrorLinePosition . errMsg) errors
  UnknownError str -> EvalErrors [str]
  NotAllowed str -> EvalErrors [str]
  GhcException str -> EvalErrors [str]


-- | List of errors output by the bot when a query string couldn't be run
-- or didn't succeed (such as GHC compiler error messages).
-- Returns a list so that they can be displayed as independent code blocks
-- if desired.
newtype EvalErrors = EvalErrors [String]
  deriving (Eq, Ord, Show)

-- | Result for input like @1 + 1@.
data EvalSuccess = EvalSuccess
  { evalSuccessValue :: String
  , evalSuccessType :: String
  } deriving (Eq, Ord, Show)

-- | Result for input like @:type 1 + 1@.
newtype TypecheckSuccess = TypecheckSuccess String -- ^ type
  deriving (Eq, Ord, Show)


-- | Result of the bot trying to run one string.
data LambdabotResult
  = ResultErrors EvalErrors
  | ResultEvalSuccess EvalSuccess
  | ResultTypecheckSuccess TypecheckSuccess
  deriving (Eq, Ord, Show)


-- | Evaluates an expression.
-- Returns the value and type on success, or the
-- error message(s) on failure.
evalCommand :: String -> IO LambdabotResult
evalCommand command = do
  let (typecheckOnly, expr)
        | Just e <- stripPrefix ":type " command = (True, e)
        | Just e <- stripPrefix ":t " command = (True, e)
        | otherwise = (False, command)
  case interpreterOpts ["--expression", expr] of
    Left (_success, output) ->
      return $ ResultErrors $ EvalErrors [output]
    Right opts -> do
      r <- runInterpreter (interpreter opts{ typeOnly = typecheckOnly })
      return $ case r of
        Left err -> ResultErrors $ formatInterpreterError err
        Right (_expr, exprType, resultVal)
          | typecheckOnly -> ResultTypecheckSuccess $ TypecheckSuccess exprType
          | otherwise -> ResultEvalSuccess $ EvalSuccess resultVal exprType


-- | Turns the result of one query string to the bot into a nicely formatted
-- Zulip message.
formatResult :: FloodProtectionOutputLength -> LambdabotResult -> Text
formatResult (FloodProtectionOutputLength maxOutputLength) result = reply
  where
    floodProtect str =
      if length str > maxOutputLength
        then take maxOutputLength str ++ "..."
        else str
    reply = case result of
      ResultErrors (EvalErrors errs) ->
        T.concat $ flip map errs $ \err -> T.unlines
          [ ":cross_mark:"
          , "```"
          , T.pack (floodProtect err)
          , "```"
          ]
      ResultTypecheckSuccess (TypecheckSuccess typ) ->
        ":check_mark: `:: " <> T.pack typ <> "`"
      ResultEvalSuccess EvalSuccess{ evalSuccessValue = val
                                   , evalSuccessType = typ } ->
        T.unlines
          [ ":check_mark:"
          , "```"
          , T.pack (floodProtect val)
          , "```"
          , "`:: " <> T.pack typ <> "`"
          ]


newtype FloodProtectionOutputLength = FloodProtectionOutputLength Int
  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)


-- | Settings for the bot.
data Settings = Settings
  { zulipUserName :: Text -- ^ Example: @lambdabot-bot@myteam.zulipchat.com@
  , zulipApiKey :: Text -- ^ Example: @abc123abc123abc123abc123abc123ab@
  , zulipBaseUrl :: Text -- ^ Example: @https://myteam.zulipchat.com/api/v1@
  , botStreams :: [Text] -- ^ Example: @["haskell"]@
  , floodProtectionOutputLength :: FloodProtectionOutputLength -- ^ Outputs beyond this length will be @...@'d.
  } deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)


-- | Main entry point for the Zulip lambdabot.
runZulipLambdabot :: Settings -> IO ()
runZulipLambdabot settings = do
  let Settings
        { zulipUserName = botUserName
        , zulipApiKey = apiKey
        , zulipBaseUrl = baseUrl
        , botStreams = botStreamsList
        , floodProtectionOutputLength
        } = settings

  let botStreamsSet = Set.fromList botStreamsList

  options <- do
    o <- zulipOptions botUserName apiKey
    return o{ clientBaseUrl = baseUrl }

  withZulip options $ do
    -- Show current subscriptions
    subscriptions <- getSubscriptions

    when (Set.fromList subscriptions /= botStreamsSet) $ do
      -- Remove undesired stream subscriptions
      removeSubscriptions [ s | s <- subscriptions, s `Set.notMember` botStreamsSet]
      -- Subscribe to desired streams
      addSubscriptions botStreamsList

    say $ "Listening to streams: " <> T.intercalate ", " (Set.toList botStreamsSet)

    me <- getProfile
    let myUserId = profileUserId me

    -- Listening for events works with a callback based API:
    onNewMessage $ \msg -> do
      let sender = messageSender msg :: User
          _senderName = userFullName sender
          subject = messageSubject msg

      -- Private messages one sends also appear as message events.
      -- Ignore them to avoid generating an infinite stream of messages.
      if userId sender == myUserId
        then liftIO $ putStrLn "Got message from myself, ignoring"
        else
          -- Only answer requests that go to the right stream.
          case messageDisplayRecipient msg of
            Right _user -> do
              liftIO $ putStrLn "Got direct message to user, ignoring"
            Left stream | stream `Set.member` botStreamsSet -> do

              -- Only answer requests that start with the evaluation symbol.
              case T.stripPrefix "> " (messageContent msg) of
                Nothing -> return ()
                Just input -> do
                  -- Run interpreter
                  result <- liftIO $ evalCommand (T.unpack input)
                  let format = formatResult floodProtectionOutputLength
                  -- Post reply
                  _msgId <- sendStreamMessage stream subject (format result)
                  return ()

            Left _stream -> do
              liftIO $ putStrLn "Got message to non-'haskell' stream, ignoring"


-- | Command line arguments for this program.
data Args = Args
  { argsSettingsFile :: FilePath -- ^ Path to the settings YAML file
  } deriving (Eq, Ord, Show)


-- | Command line argument parser for this program.
argsParser :: Opts.Parser Args
argsParser =
  Args
    <$> Opts.strOption
          ( Opts.long "config-file"
         <> Opts.metavar "FILE"
         <> Opts.value "settings.yaml"
         <> Opts.help "Path to configuration file"
          )


-- | Parses command line arguments for this program.
parseCommandLine :: IO Args
parseCommandLine = Opts.execParser $ Opts.info (Opts.helper <*> argsParser) Opts.fullDesc


runServer :: IO ()
runServer = do
  hSetBuffering stdout LineBuffering

  Args{ argsSettingsFile } <- parseCommandLine
  settings <- loadYamlSettings [argsSettingsFile] [] useEnv

  runZulipLambdabot settings