Begin implementing the euphoria API
This commit is contained in:
parent
3e8cadd677
commit
ee55f84c34
3 changed files with 541 additions and 0 deletions
279
src/Haboli/Euphoria/Api.hs
Normal file
279
src/Haboli/Euphoria/Api.hs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Haboli.Euphoria.Api
|
||||
(
|
||||
-- * Basic types
|
||||
AuthOption(..)
|
||||
, Message(..)
|
||||
, PersonalAccountView
|
||||
, SessionView(..)
|
||||
, Snowflake
|
||||
, UserType(..)
|
||||
, UserId(..)
|
||||
-- * Asynchronous events
|
||||
, BounceEvent
|
||||
, DisconnectEvent
|
||||
, HelloEvent
|
||||
, JoinEvent
|
||||
, LoginEvent
|
||||
, LogoutEvent
|
||||
, NetworkEvent
|
||||
, NickEvent
|
||||
, EditMessageEvent
|
||||
, PartEvent
|
||||
, PingEvent
|
||||
, PmInitiateEvent
|
||||
, SendEvent
|
||||
, SnapshotEvent
|
||||
-- * Session commands
|
||||
-- ** auth
|
||||
, AuthCommand
|
||||
, AuthReply
|
||||
-- ** ping
|
||||
, PingCommand
|
||||
, PingReply
|
||||
) where
|
||||
|
||||
import Data.Aeson
|
||||
import Data.Aeson.Types
|
||||
import qualified Data.Text as T
|
||||
import Data.Time
|
||||
import Data.Time.Clock.POSIX
|
||||
|
||||
{- Basic types -}
|
||||
|
||||
data AuthOption = Passcode
|
||||
deriving (Show)
|
||||
|
||||
instance ToJSON AuthOption where
|
||||
toJSON Passcode = String "passcode"
|
||||
|
||||
instance FromJSON AuthOption where
|
||||
parseJSON (String "passcode") = pure Passcode
|
||||
parseJSON (String _) = fail "invalid value"
|
||||
parseJSON v = typeMismatch "String" v
|
||||
|
||||
-- | A 'Message' is a node in a room’s log. It corresponds to a chat message, or
|
||||
-- a post, or any broadcasted event in a room that should appear in the log. See
|
||||
-- <http://api.euphoria.io/#message>.
|
||||
data Message = Message
|
||||
{ msgId :: Snowflake
|
||||
, msgParent :: Maybe Snowflake
|
||||
-- , msgPreviousEditId :: Maybe Snowflake
|
||||
, msgTime :: UTCTime
|
||||
, msgSender :: SessionView
|
||||
, msgContent :: T.Text
|
||||
-- , msgEncryptionKeyId :: Maybe T.Text
|
||||
-- , msgEdited :: Maybe UTCTime
|
||||
, msgDeleted :: Maybe UTCTime
|
||||
, msgTruncated :: Bool
|
||||
} deriving (Show)
|
||||
|
||||
instance FromJSON Message where
|
||||
parseJSON (Object v) = Message
|
||||
<$> v .: "id"
|
||||
<*> v .:? "parent"
|
||||
-- <*> v .:? "previous_edit_id"
|
||||
<*> (posixSecondsToUTCTime <$> v .: "time")
|
||||
<*> v .: "sender"
|
||||
<*> v .: "content"
|
||||
-- <*> v .:? "encryption_key_id"
|
||||
-- <*> v .:? "edited"
|
||||
<*> (fmap posixSecondsToUTCTime <$> v .:? "deleted")
|
||||
<*> v .:? "truncated" .!= False
|
||||
parseJSON v = typeMismatch "Object" v
|
||||
|
||||
data PersonalAccountView = PersonalAccountView
|
||||
{ pavId :: Snowflake
|
||||
, pavName :: T.Text
|
||||
, pavEmail :: T.Text
|
||||
} deriving (Show)
|
||||
|
||||
-- | A 'SessionView' describes a session and its identity. See
|
||||
-- <http://api.euphoria.io/#sessionview>.
|
||||
data SessionView = SessionView
|
||||
{ svId :: UserId
|
||||
, svNick :: T.Text
|
||||
, svServerId :: T.Text
|
||||
, svServerEra :: T.Text
|
||||
, svSessionId :: T.Text
|
||||
, svIsStaff :: Bool
|
||||
, svIsManager :: Bool
|
||||
-- , svClientAddress :: Maybe T.Text
|
||||
-- , svRealClientAddress :: Maybe T.Text
|
||||
} deriving (Show)
|
||||
|
||||
instance FromJSON SessionView where
|
||||
parseJSON (Object v) = SessionView
|
||||
<$> v .: "id"
|
||||
<*> v .: "name"
|
||||
<*> v .: "server_id"
|
||||
<*> v .: "server_era"
|
||||
<*> v .: "session_id"
|
||||
<*> v .:? "is_staff" .!= False
|
||||
<*> v .:? "is_manager" .!= False
|
||||
-- <*> v .:? "client_address"
|
||||
-- <*> v .:? "real_client_address"
|
||||
parseJSON v = typeMismatch "Object" v
|
||||
|
||||
-- | A snowflake is a 13-character string, usually used as a unique identifier
|
||||
-- for some type of object. It is the base-36 encoding of an unsigned, 64-bit
|
||||
-- integer. See <http://api.euphoria.io/#snowflake>.
|
||||
type Snowflake = T.Text
|
||||
|
||||
-- | The type of session a client may have.
|
||||
data UserType
|
||||
= Agent
|
||||
-- ^ The client is a person that is not logged in to any account.
|
||||
| Account
|
||||
-- ^ The client is a person that is logged into an account.
|
||||
| Bot
|
||||
-- ^ The client is a bot. Bots can never be logged in.
|
||||
| Other
|
||||
-- ^ The client has none of the other user types. While this value does not
|
||||
-- occur nowadays, some messages in the room logs are still from a time before
|
||||
-- the distinction of user types were introduced.
|
||||
deriving (Show, Eq)
|
||||
|
||||
-- | A 'UserId' identifies a user. It consists of two parts: The type of
|
||||
-- session, and a unique value for that type of session. See
|
||||
-- <http://api.euphoria.io/#userid>.
|
||||
data UserId = UserId
|
||||
{ userType :: UserType
|
||||
, userSnowflake :: Snowflake
|
||||
} deriving (Show, Eq)
|
||||
|
||||
instance FromJSON UserId where
|
||||
parseJSON (String v) = case T.breakOn ":" v of
|
||||
(snowflake, "") -> pure $ UserId Other snowflake
|
||||
("agent", snowflake) -> pure $ UserId Agent $ T.drop 1 snowflake
|
||||
("account", snowflake) -> pure $ UserId Account $ T.drop 1 snowflake
|
||||
("bot", snowflake) -> pure $ UserId Bot $ T.drop 1 snowflake
|
||||
_ -> fail "invalid user id label"
|
||||
parseJSON v = typeMismatch "String" v
|
||||
|
||||
{- Asynchronous events -}
|
||||
|
||||
data BounceEvent = BounceEvent
|
||||
{ bounceReason :: Maybe T.Text
|
||||
, bounceAuthOption :: [AuthOption]
|
||||
} deriving (Show)
|
||||
|
||||
data DisconnectEvent = DisconnectEvent
|
||||
{ disconnectReason :: T.Text
|
||||
} deriving (Show)
|
||||
|
||||
--TODO: Merge the account stuff with the PersonalAccountView?
|
||||
data HelloEvent = HelloEvent
|
||||
{ helloAccount :: Maybe PersonalAccountView
|
||||
, helloSessionView :: SessionView
|
||||
, helloAccountHasAccess :: Maybe Bool
|
||||
, helloAccountEmailVerified :: Maybe Bool
|
||||
, helloRoomIsPrivate :: Bool
|
||||
, helloVersion :: T.Text
|
||||
} deriving (Show)
|
||||
|
||||
data JoinEvent = JoinEvent
|
||||
{ joinSession :: SessionView
|
||||
} deriving (Show)
|
||||
|
||||
data LoginEvent = LoginEvent
|
||||
{ loginAccountId :: Snowflake
|
||||
} deriving (Show)
|
||||
|
||||
data LogoutEvent = LogoutEvent
|
||||
deriving (Show)
|
||||
|
||||
data NetworkEvent = NetworkEvent
|
||||
{ networkType :: T.Text -- always "partition"
|
||||
, networkServerId :: T.Text
|
||||
, networkServerEra :: T.Text
|
||||
} deriving (Show)
|
||||
|
||||
data NickEvent = NickEvent
|
||||
{ nickSessionId :: T.Text
|
||||
, nickId :: UserId
|
||||
, nickFrom :: T.Text
|
||||
, nickTo :: T.Text
|
||||
} deriving (Show)
|
||||
|
||||
data EditMessageEvent = EditMessageEvent
|
||||
{ editMessageMessage :: Message
|
||||
, editMessageEditId :: Snowflake
|
||||
} deriving (Show)
|
||||
|
||||
data PartEvent = PartEvent
|
||||
{ partSession :: SessionView
|
||||
} deriving (Show)
|
||||
|
||||
data PingEvent = PingEvent
|
||||
{ pingTime :: UTCTime
|
||||
, pingNext :: UTCTime
|
||||
} deriving (Show)
|
||||
|
||||
data PmInitiateEvent = PmInitiateEvent
|
||||
{ pmInitiateFrom :: UserId
|
||||
, pmInitiateFromNick :: T.Text
|
||||
, pmInitiateFromRoom :: T.Text
|
||||
, pmInitiatePmId :: Snowflake
|
||||
} deriving (Show)
|
||||
|
||||
data SendEvent = SendEvent
|
||||
{ sendMessage :: Message
|
||||
} deriving (Show)
|
||||
|
||||
data SnapshotEvent = SnapshotEvent
|
||||
{ snapshotIdentity :: UserId
|
||||
, snapshotVersion :: T.Text
|
||||
, snapshotListing :: [SessionView]
|
||||
, snapshotLog :: [Message]
|
||||
, snapshotNick :: Maybe T.Text
|
||||
, snapshotPmWithNick :: T.Text
|
||||
, snapshotPmWithUserId :: UserId
|
||||
} deriving (Show)
|
||||
|
||||
{- Session commands -}
|
||||
|
||||
{- auth -}
|
||||
|
||||
data AuthCommand = AuthWithPasscode T.Text
|
||||
deriving (Show)
|
||||
|
||||
data AuthReply = AuthSuccessful | AuthFailed T.Text
|
||||
deriving (Show)
|
||||
|
||||
{- ping -}
|
||||
|
||||
data PingCommand = PingCommand UTCTime
|
||||
deriving (Show)
|
||||
|
||||
data PingReply = PingReply UTCTime
|
||||
deriving (Show)
|
||||
|
||||
{- Chat room commands -}
|
||||
|
||||
{- nick -}
|
||||
|
||||
data NickCommand = NickCommand T.Text
|
||||
deriving (Show)
|
||||
|
||||
instance ToJSON NickCommand where
|
||||
toJSON (NickCommand nick) = object
|
||||
[ "type" .= String "nick"
|
||||
, "data" .= object ["name" .= nick]
|
||||
]
|
||||
|
||||
data NickReply = NickReply
|
||||
{ nickReplySessionId :: T.Text
|
||||
, nickReplyId :: UserId
|
||||
, nickReplyFrom :: T.Text
|
||||
, nickReplyTo :: T.Text
|
||||
} deriving (Show)
|
||||
|
||||
instance FromJSON NickReply where
|
||||
parseJSON (Object o) = NickReply
|
||||
<$> o .: "session_id"
|
||||
<*> o .: "id"
|
||||
<*> o .: "from"
|
||||
<*> o .: "to"
|
||||
parseJSON v = typeMismatch "Object" v
|
||||
250
src/Haboli/Euphoria/Client.hs
Normal file
250
src/Haboli/Euphoria/Client.hs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||
{-# LANGUAGE RankNTypes #-}
|
||||
|
||||
module Haboli.Euphoria.Client where
|
||||
|
||||
import Control.Exception
|
||||
import Control.Concurrent
|
||||
import Control.Concurrent.Chan
|
||||
import Data.Foldable
|
||||
import Control.Monad.Trans.Class
|
||||
import Data.Traversable
|
||||
import Control.Concurrent.MVar
|
||||
import Control.Monad
|
||||
import Control.Monad.IO.Class
|
||||
import Control.Monad.Trans.Except
|
||||
import Control.Monad.Trans.Reader
|
||||
import Control.Concurrent.STM
|
||||
import Control.Concurrent.STM.TVar
|
||||
import Control.Concurrent.STM.TMVar
|
||||
import Data.Aeson
|
||||
import qualified Data.Map.Strict as Map
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.IO as T
|
||||
import Data.Time
|
||||
import Network.Socket
|
||||
import qualified Network.WebSockets as WS
|
||||
import qualified Wuss as WSS
|
||||
|
||||
import Haboli.Euphoria.Api
|
||||
|
||||
--TODO: Add all the events
|
||||
-- | An event sent by the server. See
|
||||
-- <http://api.euphoria.io/#asynchronous-events>.
|
||||
data ServerEvent
|
||||
= ServerHello HelloEvent
|
||||
| ServerSnapshot SnapshotEvent
|
||||
deriving (Show)
|
||||
|
||||
-- | An event coming from the connection to the server.
|
||||
data Event
|
||||
= EventServer ServerEvent
|
||||
-- ^ The server has sent an event.
|
||||
| EventStopped
|
||||
-- ^ The connection has been closed. This event is always the last event and
|
||||
-- after this event, no other event will come from the connection.
|
||||
deriving (Show)
|
||||
|
||||
--TODO: Decide between Exception and Error
|
||||
--TODO: Add more exceptions for other things that can also go wrong (parsing, connection already closed, ...)
|
||||
data ClientException e
|
||||
= ServerException (Maybe T.Text) (Maybe T.Text)
|
||||
-- ^ @'ServerError' error throttled@ is an error sent by the server in
|
||||
-- response to a command. @error@ is a message that appears if a command
|
||||
-- fails. @throttled@ is a message that appears if the client should slow down
|
||||
-- its command rate.
|
||||
| StoppedException
|
||||
| DecodeException T.Text
|
||||
-- ^ At some point during decoding a websocket packet, something went wrong.
|
||||
| CustomError e
|
||||
deriving (Show)
|
||||
|
||||
-- | This type is used by the websocket thread to send the server's replies to
|
||||
-- the client. Since exceptions like a 'ServerError' may occur, they are
|
||||
-- explicitly included in the type stored in the 'MVar'.
|
||||
--
|
||||
-- The fancy types are there so I don't have to explicitly specify the response
|
||||
-- in some sum type or similar.
|
||||
newtype AwaitingReply e
|
||||
= AwaitingReply (forall r. FromJSON r => Either (ClientException e) r)
|
||||
|
||||
-- | A 'Map.Map' of empty 'TMVar's waiting for their respective reply packet
|
||||
-- from the server.
|
||||
type AwaitingReplies e = Map.Map T.Text (TMVar (AwaitingReply e))
|
||||
|
||||
data ClientInfo e = ClientInfo
|
||||
{ ciDetails :: ConnectionDetails
|
||||
, ciConnection :: WS.Connection
|
||||
, ciPacketId :: TVar Integer
|
||||
, ciWsThreadId :: ThreadId
|
||||
, ciAwaiting :: TVar (AwaitingReplies e)
|
||||
, ciEventChan :: Chan Event
|
||||
, ciStopped :: TVar Bool -- only modified by websocket thread
|
||||
}
|
||||
|
||||
-- This type declaration feels lispy in its parenthesisness
|
||||
newtype Client e a = Client (ReaderT (ClientInfo e)
|
||||
(ExceptT (ClientException e)
|
||||
IO) a)
|
||||
deriving (Functor, Applicative, Monad, MonadIO)
|
||||
|
||||
{- The websocket listening thread -}
|
||||
|
||||
ignoringInvalidMessages :: WS.ConnectionException -> IO ()
|
||||
ignoringInvalidMessages (WS.ParseException message) = putStrLn $ "ParseException: " ++ message
|
||||
ignoringInvalidMessages (WS.UnicodeException message) = putStrLn $ "UnicodeException: " ++ message
|
||||
ignoringInvalidMessages e = throwIO e
|
||||
|
||||
cancelAllReplies :: TVar (AwaitingReplies e) -> STM ()
|
||||
cancelAllReplies awaiting = do
|
||||
replyMap <- readTVar awaiting
|
||||
for_ replyMap $ \v ->
|
||||
putTMVar v (AwaitingReply (Left StoppedException))
|
||||
|
||||
wsThread :: WS.Connection -> Chan Event -> TVar (AwaitingReplies e) -> TVar Bool -> IO ()
|
||||
wsThread connection eventChan awaiting stopped
|
||||
= handle stopHandler
|
||||
$ forever
|
||||
$ handle ignoringInvalidMessages
|
||||
$ do
|
||||
msg <- WS.receiveData connection
|
||||
--TODO: Actually parse the stuff and send it to the event channel
|
||||
T.putStrLn msg
|
||||
where
|
||||
stopHandler :: WS.ConnectionException -> IO ()
|
||||
stopHandler _ = do
|
||||
-- After 'stopped' is set to True, 'awaiting' is not modified by any
|
||||
-- thread. Because of this, the call to 'cancelAllReplies' wouldn't need
|
||||
-- to happen atomically with setting 'stopped' to True, but I still do it
|
||||
-- atomically.
|
||||
atomically $ writeTVar stopped True >> cancelAllReplies awaiting
|
||||
writeChan eventChan EventStopped
|
||||
|
||||
{- Running the Client monad -}
|
||||
|
||||
data ConnectionDetails = ConnectionDetails
|
||||
{ cdHost :: HostName
|
||||
, cdPort :: PortNumber
|
||||
, cdPath :: String
|
||||
, cdPingInterval :: Int
|
||||
} deriving (Show)
|
||||
|
||||
defaultDetails :: ConnectionDetails
|
||||
defaultDetails = ConnectionDetails
|
||||
{ cdHost = "euphoria.io"
|
||||
, cdPort = 443
|
||||
, cdPath = "/room/test/ws"
|
||||
, cdPingInterval = 10
|
||||
}
|
||||
|
||||
runClient :: ConnectionDetails -> Client e a -> IO (Either (ClientException e) a)
|
||||
runClient details (Client stack)
|
||||
= withSocketsDo
|
||||
$ WSS.runSecureClient (cdHost details) (cdPort details) (cdPath details)
|
||||
$ \connection -> do
|
||||
packetId <- newTVarIO 0
|
||||
awaiting <- newTVarIO Map.empty
|
||||
eventChan <- newChan
|
||||
stopped <- newTVarIO False
|
||||
|
||||
wsThreadId <- forkIO
|
||||
$ WS.withPingThread connection (cdPingInterval details) (pure ())
|
||||
$ wsThread connection eventChan awaiting stopped
|
||||
|
||||
let info = ClientInfo
|
||||
{ ciDetails = details
|
||||
, ciConnection = connection
|
||||
, ciPacketId = packetId
|
||||
, ciWsThreadId = wsThreadId
|
||||
, ciAwaiting = awaiting
|
||||
, ciEventChan = eventChan
|
||||
, ciStopped = stopped
|
||||
}
|
||||
runExceptT $ runReaderT stack info
|
||||
|
||||
{- Private operations -}
|
||||
|
||||
throwRaw :: ClientException e -> Client e a
|
||||
throwRaw e = Client $ lift $ throwE e
|
||||
|
||||
getClientInfo :: Client e (ClientInfo e)
|
||||
getClientInfo = Client ask
|
||||
|
||||
newPacketId :: Client e T.Text
|
||||
newPacketId = do
|
||||
packetIdTVar <- ciPacketId <$> getClientInfo
|
||||
liftIO $ atomically $ do
|
||||
currentId <- readTVar packetIdTVar
|
||||
modifyTVar packetIdTVar (+1)
|
||||
pure $ T.pack $ show currentId
|
||||
|
||||
-- | Attempt to send a message via the websocket connection, catching and
|
||||
-- re-throwing all relevant exceptions inside the 'Client' monad.
|
||||
safeSend :: ToJSON a => WS.Connection -> a -> Client e ()
|
||||
safeSend connection packet = do
|
||||
result <- liftIO
|
||||
$ handle convertToException
|
||||
$ Nothing <$ WS.sendTextData connection (encode packet)
|
||||
case result of
|
||||
Nothing -> pure ()
|
||||
Just e -> throwRaw e
|
||||
where
|
||||
convertToException :: WS.ConnectionException -> IO (Maybe (ClientException e))
|
||||
convertToException (WS.CloseRequest _ _) = pure $ Just StoppedException
|
||||
convertToException WS.ConnectionClosed = pure $ Just StoppedException
|
||||
convertToException (WS.ParseException message) = pure $ Just $ DecodeException
|
||||
$ "could not parse websockets stream, client sent garbage: " <> T.pack message
|
||||
convertToException (WS.UnicodeException message) = pure $ Just $ DecodeException
|
||||
$ "could not decode unicode: " <> T.pack message
|
||||
|
||||
-- | Send a packet and automatically add a packet id
|
||||
sendPacket :: Object -> Client e T.Text
|
||||
sendPacket packet = do
|
||||
connection <- ciConnection <$> getClientInfo
|
||||
-- No need to check if 'ciStopped' is True because 'WS.sendTextData' will
|
||||
-- throw an exception anyways.
|
||||
packetId <- newPacketId
|
||||
let packetWithId = packet <> ("id" .= packetId)
|
||||
safeSend connection packetWithId
|
||||
pure packetId
|
||||
|
||||
-- | Send a packet and wait for a reply from the server.
|
||||
sendPacketWithReply :: FromJSON r => Object -> Client e r
|
||||
sendPacketWithReply packet = do
|
||||
info <- getClientInfo
|
||||
packetId <- sendPacket packet
|
||||
maybeReplyVar <- liftIO $ atomically $ do
|
||||
stopped <- readTVar $ ciStopped info
|
||||
if stopped
|
||||
then pure Nothing
|
||||
else do
|
||||
replyVar <- newEmptyTMVar
|
||||
modifyTVar (ciAwaiting info) (Map.insert packetId replyVar)
|
||||
pure $ Just replyVar
|
||||
case maybeReplyVar of
|
||||
Nothing -> throwRaw StoppedException
|
||||
Just replyVar -> do
|
||||
(AwaitingReply reply) <- liftIO $ atomically $ do
|
||||
reply <- readTMVar replyVar
|
||||
modifyTVar (ciAwaiting info) (Map.delete packetId)
|
||||
pure reply
|
||||
case reply of
|
||||
Left e -> throwRaw e
|
||||
Right r -> pure r
|
||||
|
||||
{- Public operations -}
|
||||
|
||||
getHost :: Client e HostName
|
||||
getHost = cdHost . ciDetails <$> getClientInfo
|
||||
|
||||
getPort :: Client e PortNumber
|
||||
getPort = cdPort . ciDetails <$> getClientInfo
|
||||
|
||||
getPath :: Client e String
|
||||
getPath = cdPath . ciDetails <$> getClientInfo
|
||||
|
||||
stop :: Client e ()
|
||||
stop = do
|
||||
ci <- getClientInfo
|
||||
liftIO $ WS.sendClose (ciConnection ci) $ T.pack "Goodbye :D"
|
||||
12
stack.yaml.lock
Normal file
12
stack.yaml.lock
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# This file was autogenerated by Stack.
|
||||
# You should not edit this file by hand.
|
||||
# For more information, please see the documentation at:
|
||||
# https://docs.haskellstack.org/en/stable/lock_files
|
||||
|
||||
packages: []
|
||||
snapshots:
|
||||
- completed:
|
||||
size: 524155
|
||||
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/14/19.yaml
|
||||
sha256: 9f79f6494473c9b46911364b94c4b5ef19ca8d35ebf62e46697cf651f198ee19
|
||||
original: lts-14.19
|
||||
Loading…
Add table
Add a link
Reference in a new issue