{-# LANGUAGE OverloadedStrings #-} ------------------------------------------------------------------------------- -- | -- Module : Web.HZulip -- Copyright : Pedro Tacla Yamada -- License : MIT (see LICENSE) -- -- Maintainer : Pedro Tacla Yamada -- Stability : unstable -- Portability : unportable -- -- A Wrapper library for the Zulip API. Works on top of a 'ReaderT' monad -- transformer, holding a 'ZulipOptions' object, which should hold the -- state and configuration for the API client. -- -- Using the library is made easier through a set of helper functions. This -- design is more concise and than passing around configuration variables; -- one could easily bypass it with the use of 'runZulip', though that isn't -- recommended. -- -- Examples are available at the github repository for this project at: -- https://github.com/yamadapc/hzulip module Web.HZulip ( Event(..) , Message(..) , Queue(..) , User(..) , ZulipOptions(..) , ZulipM , EventCallback , MessageCallback , addSubscriptions , addAllSubscriptions , defaultBaseUrl , eventTypes , getEvents , getStreams , getStreamSubscribers , getSubscriptions , onNewEvent , onNewMessage , registerQueue , removeSubscriptions , runZulip , sendMessage , sendPrivateMessage , sendStreamMessage , sinkZulipMessages , sourceZulipEvents , sourceZulipMessages , withZulip , withZulipCreds , zulipOptions , lift , ask ) where import Control.Arrow (second) import Control.Concurrent.STM (TBQueue, atomically, writeTBQueue) import Control.Lens ((^..)) import Control.Monad (void) import Control.Monad.Catch (catch, throwM) import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans (lift) import Control.Monad.Trans.Reader (ask, runReaderT) import Data.Aeson (decode) import Data.Aeson.Lens (key, values, _String) import qualified Data.ByteString.Lazy as BL (ByteString) import qualified Data.ByteString.Char8 as C (pack) import qualified Data.ByteString.Lazy.Char8 as CL (unpack) import Data.Conduit (Sink, Source, await) import Data.Conduit.Async (gatherFrom) import Data.List (intercalate) import Data.Text as T (Text, unpack) import Data.Text.Encoding as T (encodeUtf8) import Network.HTTP.Client (Request, HttpException(..), applyBasicAuth, httpLbs, method, newManager, parseUrl, responseBody, setQueryString) import Network.HTTP.Client.MultipartFormData (formDataBody, partBS) import Network.HTTP.Client.TLS (tlsManagerSettings) import Network.HTTP.Types (Method, methodGet, methodPatch, methodPost) import Web.HZulip.Types as ZT -- Public functions: ------------------------------------------------------------------------------- -- | -- Helper for creating a `ZulipOptions` object with the `baseUrl` set to -- `defaultBaseUrl` zulipOptions :: String -> String -> IO ZulipOptions zulipOptions e k = do manager <- newManager tlsManagerSettings return $ ZulipOptions e k defaultBaseUrl manager -- | -- Helper to run Actions in the Zulip Monad runZulip :: ZulipM a -> ZulipOptions -> IO a runZulip = runReaderT -- | -- Flipped version of 'runZulip' withZulip :: ZulipOptions -> ZulipM a -> IO a withZulip = flip runZulip -- | -- Helper for creating a minimal 'ZulipOptions' object and running an action -- in the 'ZulipM' monad withZulipCreds :: String -> String -> ZulipM a -> IO a withZulipCreds e k action = do opts <- zulipOptions e k runZulip action opts -- | -- The default zulip API URL defaultBaseUrl :: String defaultBaseUrl = "https://api.zulip.com/v1" -- | -- The list of all avaiable event types eventTypes :: [String] eventTypes = ["message", "subscriptions", "realm_user", "pointer"] -- | -- This wraps `POST https://api.zulip.com/v1/messages` with a nicer root -- API. Simpler helpers for each specific case of this somewhat overloaded -- endpoint will also be provided in the future. -- -- It takes the message `mtype`, `mrecipients`, `msubject` and `mcontent` -- and returns the created message's `id` in the `ZulipM` monad. sendMessage :: String -> [String] -> String -> String -> ZulipM Int sendMessage mtype mrecipients msubject mcontent = do let form = [ ("type" , mtype) , ("content", mcontent) , ("to" , show mrecipients) , ("subject", msubject) ] body <- zulipMakeRequest Messages methodPost form >>= decodeResponse let Just mid = responseMessageId body in return mid -- | -- Helper for sending private messages. Takes the list of recipients and -- the message's content. sendPrivateMessage :: [String] -> String -> ZulipM Int sendPrivateMessage mrs = sendMessage "private" mrs "" -- | -- Helper for sending stream messages. Takes the stream name, the subject -- and the message. sendStreamMessage :: String -> String -> String -> ZulipM Int sendStreamMessage s = sendMessage "stream" [s] -- | -- This registers a new event queue with the zulip API. It's a lower level -- function, which shouldn't be used unless you know what you're doing. It -- takes a list of names of the events you want to listen for and whether -- you'd like for the content to be rendered in HTML format -- (if you set the last parameter to `False` it will be kept as typed, in -- markdown format) registerQueue :: [String] -> Bool -> ZulipM Queue registerQueue evTps mdn = do let form = [ ("event_types" , show evTps) , ("apply_markdown", if mdn then "true" else "false") ] body <- zulipMakeRequest Register methodPost form >>= decodeResponse let Just qid = responseQueueId body Just lid = responseLastEventId body in return $ Queue qid lid -- | -- Get a list of all the public streams getStreams :: ZulipM [String] getStreams = do r <- zulipMakeRequest Streams methodGet [] return $ map T.unpack $ r ^.. key "streams" . values . key "name" . _String -- | -- Get all the user emails subscribed to a stream getStreamSubscribers :: String -> ZulipM [String] getStreamSubscribers s = do r <- zulipMakeRequest' ("/streams/" ++ s ++ "/members") methodGet [] return $ map T.unpack $ r ^.. key "subscribers" . values . _String -- | -- Get a list of the streams the client is currently subscribed to. getSubscriptions :: ZulipM [String] getSubscriptions = do r <- zulipMakeRequest Subscriptions methodGet [] return $ map T.unpack $ r ^.. key "subscriptions" . values . key "name" . _String -- | -- Subscribes the client to all available streams and returns all the -- stream names addAllSubscriptions :: ZulipM [String] addAllSubscriptions = do ss <- getStreams addSubscriptions ss return ss -- | -- Add new Stream subscriptions to the client. addSubscriptions :: [String] -> ZulipM () addSubscriptions sbs = do let sbs' = intercalate "," $ map (\s -> "{\"name\":" ++ show s ++ "}") sbs form = [ ("add", "[" ++ sbs' ++ "]") ] void $ zulipMakeRequest Subscriptions methodPatch form -- | -- Remove one or more Stream subscriptions from the client removeSubscriptions :: [String] -> ZulipM () removeSubscriptions sbs = do let form = [ ("delete", show sbs ) ] void $ zulipMakeRequest Subscriptions methodPatch form -- | -- Fetches new set of events from a `Queue`. getEvents :: Queue -> Bool -> ZulipM (Queue, [Event]) getEvents q b = do let qs = [ ("queue_id" , queueId q) , ("last_event_id", show $ lastEventId q) , ("dont_block" , if b then "true" else "false") ] body <- zulipMakeRequest Events methodGet qs >>= decodeResponse let Just evs = responseEvents body -- Get the last event id and pass it back with the `Queue` lEvId = maximum $ map eventId evs in return (q { lastEventId = lEvId }, evs) -- | -- Registers an event callback for specified events and keeps executing it -- over events as they come in onNewEvent :: [String] -> EventCallback -> ZulipM () onNewEvent etypes f = do q <- registerQueue etypes False -- We let it fail here, so that failures can be catched and handled by -- the user loop q where getEvents' q = catch (getEvents q False) (onTimeout q) loop q = do (q', evts) <- getEvents' q mapM_ f evts loop q' onTimeout q ResponseTimeout = getEvents' q onTimeout _ ex = throwM ex -- | -- Registers a callback to be executed whenever a message comes in. Will -- loop forever onNewMessage :: MessageCallback -> ZulipM () onNewMessage f = onNewEvent ["message"] $ \evt -> -- I could just pattern match here, as I did in other places and simply -- expect the Zulip API not to give us correct responses, but I think -- this is more reasonable. maybe (return ()) f (eventMessage evt) -- Higher-level conduit interface: ------------------------------------------------------------------------------- -- | -- A sink representation of the zulip messaging API, takes a tuple with the -- arguments for 'sendMessage' and sends it sinkZulipMessages :: Sink (String, [String], String, String) ZulipM () sinkZulipMessages = loop where loop = await >>= maybe (return ()) (\(w, x, y, z) -> do void $ lift $ sendMessage w x y z loop) -- | -- Creates a conduit 'Source' of zulip events sourceZulipEvents :: Int -- ^ The size of the event buffer -> [String] -- ^ A list of event types to subscribe to -> Source ZulipM Event sourceZulipEvents bufSize evts = gatherFrom bufSize $ onNewEvent evts . zulipWriteTBQueueIO -- | -- Creates a conduit 'Source' of zulip messages sourceZulipMessages :: Int -- ^ The size of the event buffer -> Source ZulipM Message sourceZulipMessages bufSize = gatherFrom bufSize $ onNewMessage . zulipWriteTBQueueIO -- Private functions: ------------------------------------------------------------------------------- data Endpoint = Messages | Register | Events | Subscriptions | Streams -- | -- Key-value pair abstraction for working with querystrings or form-data type RequestData = [(T.Text, String)] -- | -- Makes a request to some @Endpoint@ in the zulip API zulipMakeRequest :: Endpoint -> Method -> RequestData -> ZulipM BL.ByteString zulipMakeRequest e = zulipMakeRequest' (endpointSuffix e) -- | -- Makes a request to some untyped URL in the zulip API. Serializes the -- data as a QueryString on GET requests and as form-data otherwise zulipMakeRequest' :: String -> Method -> RequestData -> ZulipM BL.ByteString zulipMakeRequest' u m d = do z <- ask req <- liftIO $ parseUrl (clientBaseUrl z ++ u) req' <- prepareRequest d req m res <- liftIO $ httpLbs req' { method = m } $ clientManager z return $ responseBody res -- | -- A helper for decoding a response in the Zulip monad decodeResponse :: BL.ByteString -> ZulipM ZT.Response decodeResponse b = case decode b of Just r -> if wasSuccessful r then return r else fail $ responseMsg r _ -> fail $ "Unexpected response from the Zulip API: " ++ CL.unpack b -- | -- Adds a QueryString or FormData body, represented by a list of tuples, -- and authenticates the request, with the current zulip state's -- credentials. prepareRequest :: RequestData -> Request -> Method -> ZulipM Request prepareRequest [] r _ = applyAuth r prepareRequest d r m | m == methodGet = applyAuth $ setQueryString (map helper d) r where helper (k, v) = (encodeUtf8 k, Just $ C.pack v) prepareRequest d r _ = applyAuth =<< formDataBody (map (uncurry partBS . second C.pack) d) r -- | -- Adds authentication to a 'Request' with the configuration in the 'ZulipM' -- monad applyAuth :: Request -> ZulipM Request applyAuth req = do ZulipOptions e k _ _ <- ask return $ applyBasicAuth (C.pack e) (C.pack k) req -- | -- Returns `True` if a response indicates success wasSuccessful :: ZT.Response -> Bool wasSuccessful = (== ResponseSuccess) . responseResult -- | -- Gets the suffix for some endpoint endpointSuffix :: Endpoint -> String endpointSuffix Messages = "/messages" endpointSuffix Events = "/events" endpointSuffix Register = "/register" endpointSuffix Subscriptions = "/users/me/subscriptions" endpointSuffix Streams = "/streams" -- | -- Lifted IO version of 'writeTBQueue' zulipWriteTBQueueIO :: TBQueue a -> a -> ZulipM () zulipWriteTBQueueIO q x = lift $ atomically $ writeTBQueue q x