diff --git a/migrations/20210612_initial.sql b/migrations/20210612_initial.sql index e93e3e686b..2619908930 100644 --- a/migrations/20210612_initial.sql +++ b/migrations/20210612_initial.sql @@ -19,7 +19,7 @@ CREATE TABLE contacts ( lcr_base TEXT NOT NULL, lcr_suffix INTEGER NOT NULL DEFAULT 0, user_id INTEGER NOT NULL REFERENCES users, - user INTEGER, -- 1 if this contact is a user + is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (user_id, local_contact_ref) ON CONFLICT FAIL, UNIQUE (user_id, lcr_base, lcr_suffix) ON CONFLICT FAIL @@ -37,18 +37,19 @@ CREATE TABLE known_servers( CREATE TABLE group_profiles ( -- shared group profiles group_profile_id INTEGER PRIMARY KEY, group_ref TEXT NOT NULL, -- this name must not contain spaces - display_name TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL, properties TEXT NOT NULL DEFAULT '{}' -- JSON with user or contact profile ); CREATE TABLE groups ( group_id INTEGER PRIMARY KEY, -- local group ID - invited_by INTEGER REFERENCES contacts ON DELETE RESTRICT, - external_group_id BLOB NOT NULL, - local_group_ref TEXT NOT NULL UNIQUE, -- local group name without spaces + local_group_ref TEXT NOT NULL, -- local group name without spaces + lgr_base TEXT NOT NULL, + lgr_suffix INTEGER NOT NULL DEFAULT 0, group_profile_id INTEGER REFERENCES group_profiles, -- shared group profile user_id INTEGER NOT NULL REFERENCES users, - UNIQUE (invited_by, external_group_id) + UNIQUE (user_id, local_group_ref) ON CONFLICT FAIL, + UNIQUE (user_id, lgr_base, lgr_suffix) ON CONFLICT FAIL ); CREATE TABLE group_members ( -- group members, excluding the local user @@ -56,10 +57,20 @@ CREATE TABLE group_members ( -- group members, excluding the local user group_id INTEGER NOT NULL REFERENCES groups ON DELETE RESTRICT, member_id BLOB NOT NULL, -- shared member ID, unique per group member_role TEXT NOT NULL DEFAULT '', -- owner, admin, member - member_status TEXT NOT NULL DEFAULT '', -- inv | con | full | off + member_status TEXT NOT NULL DEFAULT '', -- new, invited, accepted, connected, ready invited_by INTEGER REFERENCES contacts (contact_id) ON DELETE RESTRICT, -- NULL for the members who joined before the current user and for the group creator - contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE RESTRICT, - UNIQUE (group_id, member_id) + contact_profile_id INTEGER NOT NULL REFERENCES contact_profiles ON DELETE RESTRICT, + contact_id INTEGER REFERENCES contacts ON DELETE RESTRICT, + UNIQUE (group_id, member_id), + UNIQUE (group_id, contact_id) +); + +CREATE TABLE group_member_intros ( + group_member_intro_id INTEGER PRIMARY KEY, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + to_group_member_id INTEGER NOT NULL REFERENCES group_members (group_member_id) ON DELETE CASCADE, + intro_status TEXT NOT NULL DEFAULT '', -- new, intro, inv, fwd, con + UNIQUE (group_member_id, to_group_member_id) ); CREATE TABLE connections ( -- all SMP agent connections @@ -68,7 +79,7 @@ CREATE TABLE connections ( -- all SMP agent connections conn_level INTEGER NOT NULL DEFAULT 0, via_contact INTEGER REFERENCES contacts (contact_id), conn_status TEXT NOT NULL, - conn_type TEXT NOT NULL, -- contact, member + conn_type TEXT NOT NULL, -- contact, member, member_direct contact_id INTEGER REFERENCES contacts ON DELETE RESTRICT, group_member_id INTEGER REFERENCES group_members ON DELETE RESTRICT, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/package.yaml b/package.yaml index cabd3cf74e..ff3c72591b 100644 --- a/package.yaml +++ b/package.yaml @@ -19,6 +19,7 @@ dependencies: - base64-bytestring >= 1.0 && < 1.3 - bytestring == 0.10.* - containers == 0.6.* + - cryptonite >= 0.27 && < 0.30 - directory == 1.3.* - exceptions == 0.10.* - file-embed == 0.0.14.* @@ -29,6 +30,7 @@ dependencies: - simple-logger == 0.1.* - simplexmq == 0.3.* - sqlite-simple == 0.4.* + - stm == 2.5.* - terminal == 0.2.* - text == 1.2.* - time == 1.9.* @@ -44,9 +46,6 @@ executables: main: Main.hs dependencies: - simplex-chat - - async == 2.2.* - - simplexmq == 0.3.* - - stm == 2.5.* ghc-options: - -threaded diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 91c60a47ae..65914a207b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -15,7 +15,8 @@ import Control.Logger.Simple import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader -import Data.Attoparsec.ByteString.Char8 (Parser, ()) +import Crypto.Random (drgNew) +import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) @@ -42,12 +43,11 @@ import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Client (smpDefaultConfig) import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Util (bshow, raceAny_) +import Simplex.Messaging.Util (raceAny_) import System.Exit (exitFailure) import System.IO (hFlush, stdout) import Text.Read (readMaybe) import UnliftIO.Async (race_) -import qualified UnliftIO.Exception as E import UnliftIO.STM data ChatCommand @@ -57,7 +57,7 @@ data ChatCommand | Connect SMPQueueInfo | DeleteContact ContactRef | SendMessage ContactRef ByteString - | NewGroup GroupRef + | NewGroup GroupProfile | AddMember GroupRef ContactRef GroupMemberRole | RemoveMember GroupRef ContactRef | MemberRole GroupRef ContactRef GroupMemberRole @@ -97,9 +97,10 @@ newChatController ChatOpts {dbFile, smpServers} t sendNotification = do currentUser <- getCreateActiveUser chatStore chatTerminal <- newChatTerminal t smpAgent <- getSMPAgentClient cfg {dbFile = dbFile <> ".agent.db", smpServers} + idsDrg <- newTVarIO =<< drgNew inputQ <- newTBQueueIO $ tbqSize cfg notifyQ <- newTBQueueIO $ tbqSize cfg - pure ChatController {currentUser, smpAgent, chatTerminal, chatStore, inputQ, notifyQ, sendNotification} + pure ChatController {currentUser, smpAgent, chatTerminal, chatStore, idsDrg, inputQ, notifyQ, sendNotification} runSimplexChat :: ChatController -> IO () runSimplexChat = runReaderT (race_ runTerminalInput runChatController) @@ -129,7 +130,7 @@ inputSubscriber = do void . runExceptT $ processChatCommand user cmd `catchError` showChatError processChatCommand :: ChatMonad m => User -> ChatCommand -> m () -processChatCommand User {userId, profile} = \case +processChatCommand user@User {userId, profile} = \case ChatHelp -> printToView chatHelpInfo MarkdownHelp -> printToView markdownInfo AddContact -> do @@ -137,30 +138,49 @@ processChatCommand User {userId, profile} = \case withStore $ \st -> createDirectConnection st userId connId showInvitation qInfo Connect qInfo -> do - connId <- withAgent $ \agent -> joinConnection agent qInfo $ encodeProfile profile + connId <- withAgent $ \a -> joinConnection a qInfo $ encodeProfile profile withStore $ \st -> createDirectConnection st userId connId DeleteContact cRef -> do conns <- withStore $ \st -> getContactConnections st userId cRef - withAgent $ \smp -> forM_ conns $ \Connection {agentConnId} -> - deleteConnection smp agentConnId `catchError` \(_ :: AgentErrorType) -> pure () + withAgent $ \a -> forM_ conns $ \Connection {agentConnId} -> + deleteConnection a agentConnId `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteContact st userId cRef unsetActive $ ActiveC cRef - when (null conns) . throwError . ChatErrorContact $ CENotFound cRef showContactDeleted cRef SendMessage cRef msg -> do - Connection {agentConnId} <- withStore $ \st -> getContactConnection st userId cRef + contact <- withStore $ \st -> getContact st userId cRef let body = MsgBodyContent {contentType = SimplexContentType XCText, contentData = msg} rawMsg = rawChatMessage ChatMessage {chatMsgId = Nothing, chatMsgEvent = XMsgNew MTText [] [body], chatDAG = Nothing} - void . withAgent $ \smp -> sendMessage smp agentConnId $ serializeRawChatMessage rawMsg + connId = contactConnId contact + void . withAgent $ \a -> sendMessage a connId $ serializeRawChatMessage rawMsg setActive $ ActiveC cRef - NewGroup _gRef -> pure () - AddMember _gRef _cRef _mRole -> pure () + NewGroup gProfile -> do + gVar <- asks idsDrg + void $ withStore $ \st -> createNewGroup st gVar user gProfile + showGroupCreated gProfile + AddMember gRef cRef memRole -> do + (group, contact) <- withStore $ \st -> (,) <$> getGroup st user gRef <*> getContact st userId cRef + let Group {groupId, groupProfile, membership, members} = group + userRole = memberRole membership + userMemberId = memberId membership + when (userRole < GRAdmin || userRole < memRole) $ throwError $ ChatError CEGroupRole + when (isMember contact members) $ throwError $ ChatError CEGroupDuplicateMember + gVar <- asks idsDrg + (agentConnId, qInfo) <- withAgent createConnection + memberId <- withStore $ \st -> createGroupMember st gVar user groupId (contactId contact) memRole IBUser agentConnId + let chatMsgEvent = XGrpInv (userMemberId, userRole) (memberId, memRole) qInfo groupProfile + rawMsg = rawChatMessage ChatMessage {chatMsgId = Nothing, chatMsgEvent, chatDAG = Nothing} + connId = contactConnId contact + void . withAgent $ \a -> sendMessage a connId $ serializeRawChatMessage rawMsg MemberRole _gRef _cRef _mRole -> pure () RemoveMember _gRef _cRef -> pure () LeaveGroup _gRef -> pure () DeleteGroup _gRef -> pure () ListMembers _gRef -> pure () SendGroupMessage _gRef _msg -> pure () + where + isMember :: Contact -> [(GroupMember, Connection)] -> Bool + isMember Contact {contactId} members = isJust $ find ((== Just contactId) . memberContactId . fst) members agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () agentSubscriber = do @@ -175,13 +195,14 @@ processAgentMessage :: forall m. ChatMonad m => User -> ConnId -> ACommand 'Agen processAgentMessage User {userId, profile} agentConnId agentMessage = do chatDirection <- withStore $ \st -> getConnectionChatDirection st userId agentConnId case chatDirection of - ReceivedDirectMessage Contact {localContactRef = c} -> + ReceivedDirectMessage (CContact ct@Contact {localContactRef = c}) -> case agentMessage of MSG meta msgBody -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody case chatMsgEvent of XMsgNew MTText [] body -> newTextMessage c meta $ find (isSimplexContentType XCText) body XInfo _ -> pure () -- TODO profile update + XGrpInv fromMem invMem qInfo groupProfile -> groupInvitation ct fromMem invMem qInfo groupProfile _ -> pure () CON -> do -- TODO update connection status @@ -193,14 +214,14 @@ processAgentMessage User {userId, profile} agentConnId agentMessage = do showToast ("@" <> c) "disconnected" unsetActive $ ActiveC c _ -> pure () - ReceivedDirectMessage NewContact {activeConn} -> + ReceivedDirectMessage (CConnection conn) -> case agentMessage of CONF confId connInfo -> do -- TODO update connection status - saveConnInfo activeConn connInfo + saveConnInfo conn connInfo withAgent $ \a -> allowConnection a agentConnId confId $ encodeProfile profile INFO connInfo -> - saveConnInfo activeConn connInfo + saveConnInfo conn connInfo _ -> pure () _ -> pure () where @@ -213,6 +234,11 @@ processAgentMessage User {userId, profile} agentConnId agentMessage = do setActive $ ActiveC c _ -> pure () + groupInvitation :: Contact -> (MemberId, GroupMemberRole) -> (MemberId, GroupMemberRole) -> SMPQueueInfo -> GroupProfile -> m () + groupInvitation _ct (fromMemId, fromRole) (memId, memRole) _qInfo _groupProfile = do + when (fromRole < GRAdmin || fromRole < memRole) $ throwError $ ChatError CEGroupRole + when (fromMemId == memId) $ throwError $ ChatError CEGroupDuplicateMember + parseChatMessage :: ByteString -> Either ChatError ChatMessage parseChatMessage msgBody = first ChatErrorMessage (parseAll rawChatMessageP msgBody >>= toChatMessage) @@ -305,26 +331,16 @@ withStore :: ChatMonad m => (forall m'. (MonadUnliftIO m', MonadError StoreError m') => SQLiteStore -> m' a) -> m a -withStore action = do - st <- asks chatStore - runExceptT (action st `E.catch` handleInternal) >>= \case - Right c -> pure c - Left e -> throwError $ storeError e - where - -- TODO when parsing exception happens in store, the agent hangs; - -- changing SQLError to SomeException does not help - handleInternal :: (MonadError StoreError m') => E.SomeException -> m' a - handleInternal e = throwError . SEInternal $ bshow e - storeError :: StoreError -> ChatError - storeError = \case - SEContactNotFound c -> ChatErrorContact $ CENotFound c - e -> ChatErrorStore e +withStore action = + asks chatStore + >>= runExceptT . action + >>= liftEither . first ChatErrorStore chatCommandP :: Parser ChatCommand chatCommandP = ("/help" <|> "/h") $> ChatHelp - <|> ("/group #" <|> "/g #") *> (NewGroup <$> groupRef) - <|> ("/add #" <|> "/a #") *> (AddMember <$> groupRef <* A.space <*> contactRef <* A.space <*> memberRole) + <|> ("/group #" <|> "/g #") *> (NewGroup <$> groupProfile) + <|> ("/add #" <|> "/a #") *> (AddMember <$> groupRef <* A.space <*> contactRef <*> memberRole) <|> ("/remove #" <|> "/rm #") *> (RemoveMember <$> groupRef <* A.space <*> contactRef) <|> ("/delete #" <|> "/d #") *> (DeleteGroup <$> groupRef) <|> ("/members #" <|> "/ms #") *> (ListMembers <$> groupRef) @@ -338,8 +354,12 @@ chatCommandP = contactRef = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) refChar c = c > ' ' && c /= '#' && c /= '@' groupRef = contactRef + groupProfile = do + gRef <- groupRef + gName <- safeDecodeUtf8 <$> (A.space *> A.takeByteString) <|> pure "" + pure GroupProfile {groupRef = gRef, displayName = if T.null gName then gRef else gName} memberRole = - ("owner" $> GROwner) - <|> ("admin" $> GRAdmin) - <|> ("normal" $> GRMember) - "memberRole" + (" owner" $> GROwner) + <|> (" admin" $> GRAdmin) + <|> (" normal" $> GRMember) + <|> pure GRMember diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9d39b28fca..fc6fa50820 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -10,6 +10,7 @@ import Control.Exception import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader +import Crypto.Random (ChaChaDRG) import Simplex.Chat.Notification import Simplex.Chat.Store (StoreError) import Simplex.Chat.Terminal @@ -24,6 +25,7 @@ data ChatController = ChatController smpAgent :: AgentClient, chatTerminal :: ChatTerminal, chatStore :: SQLiteStore, + idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue InputEvent, notifyQ :: TBQueue Notification, sendNotification :: Notification -> IO () @@ -32,14 +34,13 @@ data ChatController = ChatController data InputEvent = InputCommand String | InputControl Char data ChatError - = ChatErrorContact ContactError + = ChatError ChatErrorType | ChatErrorMessage String | ChatErrorAgent AgentErrorType | ChatErrorStore StoreError deriving (Show, Exception) -data ContactError = CENotFound ContactRef | CEProfile String - deriving (Show, Exception) +data ChatErrorType = CEGroupRole | CEGroupDuplicateMember deriving (Show, Exception) type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 1449ca9690..6fba8f3b77 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -12,7 +12,6 @@ module Simplex.Chat.Protocol where import Control.Applicative (optional) import Control.Monad ((<=<)) -import Control.Monad.Except (throwError) import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) @@ -30,44 +29,38 @@ import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Util (bshow) data ChatDirection (p :: AParty) where - ReceivedDirectMessage :: Contact -> ChatDirection 'Agent - SentDirectMessage :: Contact -> ChatDirection 'Client - ReceivedGroupMessage :: Group -> Contact -> ChatDirection 'Agent + ReceivedDirectMessage :: ConnContact -> ChatDirection 'Agent + SentDirectMessage :: ConnContact -> ChatDirection 'Client + ReceivedGroupMessage :: Group -> ConnContact -> ChatDirection 'Agent SentGroupMessage :: Group -> ChatDirection 'Client deriving instance Eq (ChatDirection p) deriving instance Show (ChatDirection p) +data ConnContact = CContact Contact | CConnection Connection + deriving (Eq, Show) + data ChatMsgEvent - = XMsgNew {messageType :: MessageType, files :: [(ContentType, Int)], content :: [MsgBodyContent]} + = XMsgNew + { messageType :: MessageType, + files :: [(ContentType, Int)], + content :: [MsgBodyContent] + } | XInfo Profile - | XGrpInv InvitationId MemberId GroupMemberRole GroupProfile - | XGrpAcpt InvitationId SMPQueueInfo + | XGrpInv + { fromMember :: (MemberId, GroupMemberRole), + invitedMember :: (MemberId, GroupMemberRole), + queueInfo :: SMPQueueInfo, + groupProfile :: GroupProfile + } + | XGrpAcpt MemberId | XGrpMemNew MemberId GroupMemberRole Profile | XGrpMemIntro MemberId GroupMemberRole Profile deriving (Eq, Show) -type MemberId = ByteString - data MessageType = MTText | MTImage deriving (Eq, Show) -data GroupMemberRole = GROwner | GRAdmin | GRMember - deriving (Eq, Show) - -toMemberRole :: ByteString -> Either String GroupMemberRole -toMemberRole = \case - "owner" -> Right GROwner - "admin" -> Right GRAdmin - "member" -> Right GRMember - r -> Left $ "invalid group member role " <> B.unpack r - -serializeMemberRole :: GroupMemberRole -> ByteString -serializeMemberRole = \case - GROwner -> "owner" - GRAdmin -> "admin" - GRMember -> "member" - toMsgType :: ByteString -> Either String MessageType toMsgType = \case "c.text" -> Right MTText @@ -96,41 +89,38 @@ toChatMessage RawChatMessage {chatMsgId, chatMsgEvent, chatMsgParams, chatMsgBod files <- mapM (toContentInfo <=< parseAll contentInfoP) rawFiles let msg = XMsgNew {messageType = t, files, content = body} pure ChatMessage {chatMsgId, chatMsgEvent = msg, chatDAG} - [] -> throwError "x.msg.new expects at least one parameter" + [] -> Left "x.msg.new expects at least one parameter" "x.info" -> case chatMsgParams of [] -> do profile <- getJSON body pure ChatMessage {chatMsgId, chatMsgEvent = XInfo profile, chatDAG} - _ -> throwError "x.info expects no parameters" + _ -> Left "x.info expects no parameters" "x.grp.inv" -> case chatMsgParams of - [invId', memId', role'] -> do - invId <- B64.decode invId' - memId <- B64.decode memId' - role <- toMemberRole role' - groupProfile <- getJSON body - pure ChatMessage {chatMsgId, chatMsgEvent = XGrpInv invId memId role groupProfile, chatDAG} - _ -> throwError "x.grp.inv expects 3 parameters" + [fromMemId, fromRole, memId, role, qInfo] -> do + fromMember <- (,) <$> B64.decode fromMemId <*> toMemberRole fromRole + invitedMember <- (,) <$> B64.decode memId <*> toMemberRole role + msg <- XGrpInv fromMember invitedMember <$> parseAll smpQueueInfoP qInfo <*> getJSON body + pure ChatMessage {chatMsgId, chatMsgEvent = msg, chatDAG} + _ -> Left "x.grp.inv expects 5 parameters" "x.grp.acpt" -> case chatMsgParams of - [invId, qInfo] -> do - msg <- XGrpAcpt <$> B64.decode invId <*> parseAll smpQueueInfoP qInfo + [memId] -> do + msg <- XGrpAcpt <$> B64.decode memId pure ChatMessage {chatMsgId, chatMsgEvent = msg, chatDAG} - _ -> throwError "x.grp.acpt expects 2 parameters" - "x.grp.mem.new" -> case chatMsgParams of - [memId, role] -> do - msg <- XGrpMemNew <$> B64.decode memId <*> toMemberRole role <*> getJSON body - pure ChatMessage {chatMsgId, chatMsgEvent = msg, chatDAG} - _ -> throwError "x.grp.acpt expects 2 parameters" - "x.grp.mem.intro" -> case chatMsgParams of - [memId, role] -> do - msg <- XGrpMemIntro <$> B64.decode memId <*> toMemberRole role <*> getJSON body - pure ChatMessage {chatMsgId, chatMsgEvent = msg, chatDAG} - _ -> throwError "x.grp.acpt expects 2 parameters" - _ -> throwError $ "unsupported event " <> B.unpack chatMsgEvent + _ -> Left "x.grp.acpt expects one parameter" + "x.grp.mem.new" -> memberMessage chatMsgParams XGrpMemNew body chatDAG + "x.grp.mem.intro" -> memberMessage chatMsgParams XGrpMemIntro body chatDAG + _ -> Left $ "unsupported event " <> B.unpack chatMsgEvent where getDAG :: [MsgBodyContent] -> (Maybe ByteString, [MsgBodyContent]) getDAG body = case break (isContentType SimplexDAG) body of (b, MsgBodyContent SimplexDAG dag : a) -> (Just dag, b <> a) _ -> (Nothing, body) + memberMessage :: + FromJSON a => [ByteString] -> (MemberId -> GroupMemberRole -> a -> ChatMsgEvent) -> [MsgBodyContent] -> Maybe ByteString -> Either String ChatMessage + memberMessage [memId, role] mkMsg body chatDAG = do + msg <- mkMsg <$> B64.decode memId <*> toMemberRole role <*> getJSON body + pure ChatMessage {chatMsgId, chatMsgEvent = msg, chatDAG} + memberMessage _ _ _ _ = Left "message expects 2 parameters" toContentInfo :: (RawContentType, Int) -> Either String (ContentType, Int) toContentInfo (rawType, size) = (,size) <$> toContentType rawType getJSON :: FromJSON a => [MsgBodyContent] -> Either String a @@ -161,12 +151,18 @@ rawChatMessage ChatMessage {chatMsgId, chatMsgEvent, chatDAG} = XInfo profile -> let chatMsgBody = rawWithDAG [jsonBody profile] in RawChatMessage {chatMsgId, chatMsgEvent = "x.info", chatMsgParams = [], chatMsgBody} - XGrpInv invId memId role groupProfile -> - let chatMsgParams = [B64.encode invId, B64.encode memId, serializeMemberRole role] + XGrpInv (fromMemId, fromRole) (memId, role) qInfo groupProfile -> + let chatMsgParams = + [ B64.encode fromMemId, + serializeMemberRole fromRole, + B64.encode memId, + serializeMemberRole role, + serializeSmpQueueInfo qInfo + ] chatMsgBody = rawWithDAG [jsonBody groupProfile] in RawChatMessage {chatMsgId, chatMsgEvent = "x.grp.inv", chatMsgParams, chatMsgBody} - XGrpAcpt invId qInfo -> - let chatMsgParams = [B64.encode invId, serializeSmpQueueInfo qInfo] + XGrpAcpt memId -> + let chatMsgParams = [B64.encode memId] in RawChatMessage {chatMsgId, chatMsgEvent = "x.grp.acpt", chatMsgParams, chatMsgBody = []} XGrpMemNew memId role profile -> let chatMsgParams = [B64.encode memId, serializeMemberRole role] diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 86a2cb9200..bc7d726d8a 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -8,6 +8,7 @@ {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store ( SQLiteStore, @@ -19,16 +20,23 @@ module Simplex.Chat.Store createDirectConnection, createDirectContact, deleteContact, - getContactConnection, + getContact, getContactConnections, getConnectionChatDirection, + createNewGroup, + createGroup, + getGroup, + createGroupMember, ) where +import Control.Concurrent.STM (stateTVar) import Control.Exception (Exception) import qualified Control.Exception as E import Control.Monad.Except import Control.Monad.IO.Unlift +import Crypto.Random (ChaChaDRG, randomBytesGenerate) +import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import Data.FileEmbed (embedDir, makeRelativeToProject) import Data.Function (on) @@ -39,7 +47,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeUtf8) import Data.Time.Clock (UTCTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), SQLError) +import Database.SQLite.Simple (NamedParam (..), Only (..), SQLError, (:.) (..)) import qualified Database.SQLite.Simple as DB import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Protocol @@ -49,6 +57,7 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import Simplex.Messaging.Util (bshow, liftIOEither, (<$$>)) import System.FilePath (takeBaseName, takeExtension) +import UnliftIO.STM -- | The list of migrations in ascending order by date migrations :: [Migration] @@ -71,22 +80,22 @@ handleSQLError err e | otherwise = SEInternal $ bshow e insertedRowId :: DB.Connection -> IO Int64 -insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid();" +insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" createUser :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> Profile -> Bool -> m User createUser st Profile {contactRef, displayName} activeUser = liftIOEither . checkConstraint SEDuplicateContactRef . withTransaction st $ \db -> do - DB.execute db "INSERT INTO contact_profiles (contact_ref, display_name) VALUES (?, ?);" (contactRef, displayName) + DB.execute db "INSERT INTO contact_profiles (contact_ref, display_name) VALUES (?, ?)" (contactRef, displayName) profileId <- insertedRowId db - DB.execute db "INSERT INTO users (contact_id, active_user) VALUES (0, ?);" (Only activeUser) + DB.execute db "INSERT INTO users (contact_id, active_user) VALUES (0, ?)" (Only activeUser) userId <- insertedRowId db DB.execute db - "INSERT INTO contacts (contact_profile_id, local_contact_ref, lcr_base, user_id, user) VALUES (?, ?, ?, ?, 1);" - (profileId, contactRef, contactRef, userId) + "INSERT INTO contacts (contact_profile_id, local_contact_ref, lcr_base, user_id, is_user) VALUES (?, ?, ?, ?, ?)" + (profileId, contactRef, contactRef, userId, True) contactId <- insertedRowId db - DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?;" (contactId, userId) - pure . Right $ toUser (userId, activeUser, contactRef, displayName) + DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) + pure . Right $ toUser (userId, contactId, activeUser, contactRef, displayName) getUsers :: SQLiteStore -> IO [User] getUsers st = @@ -95,22 +104,22 @@ getUsers st = <$> DB.query_ db [sql| - SELECT u.user_id, u.active_user, c.local_contact_ref, p.display_name + SELECT u.user_id, u.contact_id, u.active_user, c.local_contact_ref, p.display_name FROM users u JOIN contacts c ON u.contact_id = c.contact_id JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id |] -toUser :: (UserId, Bool, ContactRef, Text) -> User -toUser (userId, activeUser, contactRef, displayName) = +toUser :: (UserId, Int64, Bool, ContactRef, Text) -> User +toUser (userId, userContactId, activeUser, contactRef, displayName) = let profile = Profile {contactRef, displayName} - in User {userId, localContactRef = contactRef, profile, activeUser} + in User {userId, userContactId, localContactRef = contactRef, profile, activeUser} setActiveUser :: MonadUnliftIO m => SQLiteStore -> UserId -> m () setActiveUser st userId = do liftIO . withTransaction st $ \db -> do - DB.execute_ db "UPDATE users SET active_user = 0;" - DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?;" (Only userId) + DB.execute_ db "UPDATE users SET active_user = 0" + DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) createDirectConnection :: MonadUnliftIO m => SQLiteStore -> UserId -> ConnId -> m () createDirectConnection st userId agentConnId = @@ -127,7 +136,7 @@ createDirectContact :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> Connection -> Profile -> m () createDirectContact st userId Connection {connId} Profile {contactRef, displayName} = liftIOEither . withTransaction st $ \db -> do - DB.execute db "INSERT INTO contact_profiles (contact_ref, display_name) VALUES (?, ?);" (contactRef, displayName) + DB.execute db "INSERT INTO contact_profiles (contact_ref, display_name) VALUES (?, ?)" (contactRef, displayName) profileId <- insertedRowId db lcrSuffix <- getLcrSuffix db create db profileId lcrSuffix 20 @@ -141,7 +150,7 @@ createDirectContact st userId Connection {connId} Profile {contactRef, displayNa SELECT lcr_suffix FROM contacts WHERE user_id = :user_id AND lcr_base = :contact_ref ORDER BY lcr_suffix DESC - LIMIT 1; + LIMIT 1 |] [":user_id" := userId, ":contact_ref" := contactRef] create :: DB.Connection -> Int64 -> Int -> Int -> IO (Either StoreError ()) @@ -179,24 +188,62 @@ deleteContact st userId contactRef = FROM connections c JOIN contacts cs ON c.contact_id = cs.contact_id WHERE cs.user_id = :user_id AND cs.local_contact_ref = :contact_ref - ); + ) |] [":user_id" := userId, ":contact_ref" := contactRef] DB.executeNamed db [sql| DELETE FROM contacts - WHERE user_id = :user_id AND local_contact_ref = :contact_ref; + WHERE user_id = :user_id AND local_contact_ref = :contact_ref |] [":user_id" := userId, ":contact_ref" := contactRef] -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getContactConnection :: - (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactRef -> m Connection -getContactConnection st userId contactRef = +getContact :: + (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactRef -> m Contact +getContact st userId localContactRef = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + c@Contact {contactId} <- getContact_ db + activeConn <- getConnection_ db contactId + pure $ (c :: Contact) {activeConn} + where + getContact_ db = ExceptT $ do + toContact + <$> DB.queryNamed + db + [sql| + SELECT c.contact_id, p.contact_ref, p.display_name + FROM contacts c + JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id + WHERE c.user_id = :user_id AND c.local_contact_ref = :local_contact_ref AND c.is_user = :is_user + |] + [":user_id" := userId, ":local_contact_ref" := localContactRef, ":is_user" := False] + getConnection_ db contactId = ExceptT $ do + connection + <$> DB.queryNamed + db + [sql| + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, + c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.created_at + FROM connections c + WHERE c.user_id = :user_id AND c.contact_id == :contact_id + ORDER BY c.connection_id DESC + LIMIT 1 + |] + [":user_id" := userId, ":contact_id" := contactId] + toContact [(contactId, contactRef, displayName)] = + let profile = Profile {contactRef, displayName} + in Right Contact {contactId, localContactRef, profile, activeConn = undefined} + toContact _ = Left $ SEContactNotFound localContactRef + connection (connRow : _) = Right $ toConnection connRow + connection _ = Left $ SEContactNotReady localContactRef + +getContactConnections :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ContactRef -> m [Connection] +getContactConnections st userId contactRef = liftIOEither . withTransaction st $ \db -> - connection + connections <$> DB.queryNamed db [sql| @@ -207,33 +254,15 @@ getContactConnection st userId contactRef = WHERE c.user_id = :user_id AND cs.user_id = :user_id AND cs.local_contact_ref == :contact_ref - ORDER BY c.connection_id DESC - LIMIT 1; |] [":user_id" := userId, ":contact_ref" := contactRef] where - connection (connRow : _) = Right $ toConnection connRow - connection _ = Left $ SEContactNotFound contactRef + connections [] = Left $ SEContactNotFound contactRef + connections rows = Right $ map toConnection rows -getContactConnections :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactRef -> m [Connection] -getContactConnections st userId contactRef = - liftIO . withTransaction st $ \db -> - map toConnection - <$> DB.queryNamed - db - [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, - c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.created_at - FROM connections c - JOIN contacts cs ON c.contact_id == cs.contact_id - WHERE c.user_id = :user_id - AND cs.user_id = :user_id - AND cs.local_contact_ref == :contact_ref; - |] - [":user_id" := userId, ":contact_ref" := contactRef] +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, ConnStatus, ConnType, Maybe Int64, Maybe Int64, UTCTime) -toConnection :: - (Int64, ConnId, Int, Maybe Int64, ConnStatus, ConnType, Maybe Int64, Maybe Int64, UTCTime) -> Connection +toConnection :: ConnectionRow -> Connection toConnection (connId, agentConnId, connLevel, viaContact, connStatus, connType, contactId, groupMemberId, createdAt) = let entityId = entityId_ connType in Connection {connId, agentConnId, connLevel, viaContact, connStatus, connType, entityId, createdAt} @@ -246,16 +275,16 @@ getConnectionChatDirection :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> UserId -> ConnId -> m (ChatDirection 'Agent) getConnectionChatDirection st userId agentConnId = liftIOEither . withTransaction st $ \db -> do - getConnection db >>= \case + getConnection_ db >>= \case Left e -> pure $ Left e Right c@Connection {connType, entityId} -> case connType of ConnMember -> pure . Left $ SEInternal "group members not supported yet" ConnContact -> ReceivedDirectMessage <$$> case entityId of - Nothing -> pure $ Right NewContact {activeConn = c} - Just cId -> getContact db cId c + Nothing -> pure . Right $ CConnection c + Just cId -> getContact_ db cId c where - getConnection db = + getConnection_ db = connection <$> DB.query db @@ -263,12 +292,12 @@ getConnectionChatDirection st userId agentConnId = SELECT connection_id, agent_conn_id, conn_level, via_contact, conn_status, conn_type, contact_id, group_member_id, created_at FROM connections - WHERE user_id = ? AND agent_conn_id = ?; + WHERE user_id = ? AND agent_conn_id = ? |] (userId, agentConnId) connection (connRow : _) = Right $ toConnection connRow connection _ = Left $ SEConnectionNotFound agentConnId - getContact db contactId c = + getContact_ db contactId c = toContact contactId c <$> DB.query db @@ -281,12 +310,233 @@ getConnectionChatDirection st userId agentConnId = (userId, contactId) toContact contactId c [(localContactRef, contactRef, displayName)] = let profile = Profile {contactRef, displayName} - in Right Contact {contactId, localContactRef, profile, activeConn = c} + in Right $ CContact Contact {contactId, localContactRef, profile, activeConn = c} toContact _ _ _ = Left $ SEInternal "referenced contact not found" +-- | creates completely new group with a single member - the current user +createNewGroup :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> TVar ChaChaDRG -> User -> GroupProfile -> m Group +createNewGroup st gVar User {userId, userContactId, profile} p@GroupProfile {groupRef, displayName} = + liftIOEither . checkConstraint SEDuplicateGroupRef . withTransaction st $ \db -> do + -- group inserted before profile to ensure its local_group_ref is unique + DB.execute db "INSERT INTO groups (local_group_ref, lgr_base, user_id) VALUES (?, ?, ?)" (groupRef, groupRef, userId) + groupId <- insertedRowId db + DB.execute db "INSERT INTO group_profiles (group_ref, display_name) VALUES (?, ?)" (groupRef, displayName) + profileId <- insertedRowId db + DB.execute db "UPDATE groups SET group_profile_id = ? WHERE group_id = ?" (profileId, groupId) + memberId <- randomId gVar 12 + createMember_ db groupId userContactId GROwner GSMemReady (Just userContactId) memberId + groupMemberId <- insertedRowId db + let membership = + GroupMember + { groupMemberId, + memberId, + memberRole = GROwner, + memberStatus = GSMemReady, + invitedBy = IBUser, + memberProfile = profile, + memberContactId = Just userContactId + } + pure $ Right Group {groupId, localGroupRef = groupRef, groupProfile = p, members = [], membership} + +-- | creates a new group record for the group the current user was invited to +createGroup :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> TVar ChaChaDRG -> User -> Contact -> GroupProfile -> m Group +createGroup st gVar User {userId, userContactId, profile} contact p@GroupProfile {groupRef, displayName} = + liftIOEither . withTransaction st $ \db -> do + DB.execute db "INSERT INTO group_profiles (group_ref, display_name) VALUES (?, ?)" (groupRef, displayName) + profileId <- insertedRowId db + lgrSuffix <- getLgrSuffix db + group <- create db profileId lgrSuffix 20 + pure group + where + -- createMember_ db groupId userContactId GROwner GSMemReady (Just userContactId) memberId + -- groupMemberId <- insertedRowId db + -- let membership = + -- GroupMember + -- { groupMemberId, + -- memberId, + -- memberRole = GROwner, + -- memberStatus = GSMemReady, + -- invitedBy = IBUser, + -- memberProfile = profile, + -- memberContactId = Just userContactId + -- } + -- pure $ Right Group {groupId, localGroupRef = groupRef, groupProfile = p, members = [], membership} + + getLgrSuffix :: DB.Connection -> IO Int + getLgrSuffix db = + maybe 0 ((+ 1) . fromOnly) . listToMaybe + <$> DB.queryNamed + db + [sql| + SELECT lgr_suffix FROM groups + WHERE user_id = :user_id AND lgr_base = :group_ref + ORDER BY lgr_suffix DESC + LIMIT 1 + |] + [":user_id" := userId, ":group_ref" := groupRef] + create :: DB.Connection -> Int64 -> Int -> Int -> IO (Either StoreError Group) + create _ _ _ 0 = pure $ Left SEDuplicateGroupRef + create db profileId lgrSuffix attempts = do + let lgr = localGroupRef' lgrSuffix + E.try (insertGroup lgr) >>= \case + Right () -> do + groupId <- insertedRowId db + pure $ Right Group {groupId, localGroupRef = lgr, groupProfile = p, members = undefined, membership = undefined} + Left e + | DB.sqlError e == DB.ErrorConstraint -> create db profileId (lgrSuffix + 1) (attempts - 1) + | otherwise -> E.throwIO e + where + localGroupRef' 0 = groupRef + localGroupRef' n = groupRef <> T.pack ('_' : show n) + insertGroup lgr = + DB.execute + db + [sql| + INSERT INTO groups + (group_profile_id, local_group_ref, lgr_base, lgr_suffix, user_id) VALUES (?, ?, ?, ?, ?) + |] + (profileId, lgr, groupRef, lgrSuffix, userId) + +-- TODO return the last connection that is ready, not any last connection +-- requires updating connection status +getGroup :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> User -> GroupRef -> m Group +getGroup st User {userId, userContactId} localGroupRef = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + g@Group {groupId} <- getGroup_ db + members <- getMembers_ db groupId + membership <- getUserMember_ db groupId + pure g {members, membership} + where + getGroup_ :: DB.Connection -> ExceptT StoreError IO Group + getGroup_ db = ExceptT $ do + toGroup + <$> DB.query + db + [sql| + SELECT g.group_id, p.group_ref, p.display_name + FROM groups g + JOIN group_profiles p ON p.group_profile_id = g.group_profile_id + WHERE g.local_group_ref = ? AND g.user_id = ? + |] + (localGroupRef, userId) + toGroup :: [(Int64, GroupRef, Text)] -> Either StoreError Group + toGroup [(groupId, groupRef, displayName)] = + let groupProfile = GroupProfile {groupRef, displayName} + in Right Group {groupId, localGroupRef, groupProfile, members = undefined, membership = undefined} + toGroup _ = Left $ SEGroupNotFound localGroupRef + getMembers_ :: DB.Connection -> Int64 -> ExceptT StoreError IO [(GroupMember, Connection)] + getMembers_ db groupId = ExceptT $ do + Right . map toContactMember + <$> DB.query + db + [sql| + SELECT + m.group_member_id, m.member_id, m.member_role, m.member_status, + m.invited_by, m.contact_id, p.contact_ref, p.display_name, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, + c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.created_at + FROM group_members m + JOIN groups g ON g.group_id = m.group_id + JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + JOIN connections c ON c.group_member_id = m.group_member_id + WHERE g.group_id = ? + ORDER BY c.connection_id DESC + LIMIT 1 + |] + (Only groupId) + getUserMember_ :: DB.Connection -> Int64 -> ExceptT StoreError IO GroupMember + getUserMember_ db groupId = ExceptT $ do + userMember + <$> DB.query + db + [sql| + SELECT + m.group_member_id, m.member_id, m.member_role, m.member_status, + m.invited_by, m.contact_id, p.contact_ref, p.display_name + FROM group_members m + JOIN groups g ON g.group_id = m.group_id + JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + WHERE g.group_id = ? AND m.contact_id = ? + |] + (groupId, userContactId) + toContactMember :: (GroupMemberRow :. ConnectionRow) -> (GroupMember, Connection) + toContactMember (memberRow :. connRow) = (toGroupMember memberRow, toConnection connRow) + toGroupMember :: GroupMemberRow -> GroupMember + toGroupMember (groupMemberId, memberId, memberRole, memberStatus, invitedById, memberContactId, contactRef, displayName) = + let memberProfile = Profile {contactRef, displayName} + invitedBy = toInvitedBy userContactId invitedById + in GroupMember {groupMemberId, memberId, memberRole, memberStatus, invitedBy, memberProfile, memberContactId} + userMember :: [GroupMemberRow] -> Either StoreError GroupMember + userMember [memberRow] = Right $ toGroupMember memberRow + userMember _ = Left SEGroupWithoutUser + +type GroupMemberRow = (Int64, ByteString, GroupMemberRole, GroupMemberStatus, Maybe Int64, Maybe Int64, ContactRef, Text) + +createGroupMember :: (MonadUnliftIO m, MonadError StoreError m) => SQLiteStore -> TVar ChaChaDRG -> User -> Int64 -> Int64 -> GroupMemberRole -> InvitedBy -> ConnId -> m MemberId +createGroupMember st gVar User {userId, userContactId} groupId contactId memberRole invitedBy agentConnId = + liftIOEither . withTransaction st $ \db -> do + let invitedById = fromInvitedBy userContactId invitedBy + memberId <- createWithRandomId gVar $ createMember_ db groupId contactId memberRole GSMemInvited invitedById + groupMemberId <- insertedRowId db + liftIO $ createMemberConnection_ db groupMemberId + pure memberId + where + createMemberConnection_ :: DB.Connection -> Int64 -> IO () + createMemberConnection_ db groupMemberId = + DB.execute + db + [sql| + INSERT INTO connections + (user_id, agent_conn_id, conn_status, conn_type, group_member_id) VALUES (?,?,?,?,?); + |] + (userId, agentConnId, ConnNew, ConnMember, groupMemberId) + +createMember_ :: DB.Connection -> Int64 -> Int64 -> GroupMemberRole -> GroupMemberStatus -> Maybe Int64 -> ByteString -> IO () +createMember_ db groupId contactId memberRole memberStatus invitedBy memberId = + DB.executeNamed + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_status, invited_by, + contact_profile_id, contact_id) + VALUES + (:group_id,:member_id,:member_role,:member_status,:invited_by, + (SELECT contact_profile_id FROM contacts WHERE contact_id = :contact_id), + :contact_id) + |] + [ ":group_id" := groupId, + ":member_id" := memberId, + ":member_role" := memberRole, + ":member_status" := memberStatus, + ":invited_by" := invitedBy, + ":contact_id" := contactId + ] + +createWithRandomId :: TVar ChaChaDRG -> (ByteString -> IO ()) -> IO (Either StoreError ByteString) +createWithRandomId gVar create = tryCreate 3 + where + tryCreate :: Int -> IO (Either StoreError ByteString) + tryCreate 0 = pure $ Left SEUniqueID + tryCreate n = do + id' <- randomId gVar 12 + E.try (create id') >>= \case + Right _ -> pure $ Right id' + Left e + | DB.sqlError e == DB.ErrorConstraint -> tryCreate (n - 1) + | otherwise -> pure . Left . SEInternal $ bshow e + +randomId :: TVar ChaChaDRG -> Int -> IO ByteString +randomId gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n) + data StoreError = SEDuplicateContactRef | SEContactNotFound ContactRef + | SEContactNotReady ContactRef + | SEDuplicateGroupRef + | SEGroupNotFound GroupRef + | SEGroupWithoutUser + | SEDuplicateGroupMember | SEConnectionNotFound ConnId + | SEUniqueID | SEInternal ByteString deriving (Show, Exception) diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs index 45e7f87c21..c344ddf4f6 100644 --- a/src/Simplex/Chat/Styled.hs +++ b/src/Simplex/Chat/Styled.hs @@ -3,7 +3,6 @@ module Simplex.Chat.Styled ( StyledString (..), - bPlain, plain, styleMarkdown, styleMarkdownText, @@ -28,12 +27,6 @@ instance Monoid StyledString where mempty = plain "" instance IsString StyledString where fromString = plain -plain :: String -> StyledString -plain = Styled [] - -bPlain :: ByteString -> StyledString -bPlain = Styled [] . B.unpack - styleMarkdownText :: Text -> StyledString styleMarkdownText = styleMarkdown . parseMarkdown @@ -48,12 +41,19 @@ wrap c s = plain [c] <> s <> plain [c] class StyledFormat a where styled :: Format -> a -> StyledString + plain :: a -> StyledString -instance StyledFormat String where styled = Styled . sgr +instance StyledFormat String where + styled = Styled . sgr + plain = Styled [] -instance StyledFormat ByteString where styled f = styled f . B.unpack +instance StyledFormat ByteString where + styled f = styled f . B.unpack + plain = Styled [] . B.unpack -instance StyledFormat Text where styled f = styled f . T.unpack +instance StyledFormat Text where + styled f = styled f . T.unpack + plain = Styled [] . T.unpack sgr :: Format -> [SGR] sgr = \case diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 6f9d089b84..81a7a5b677 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Types where @@ -8,10 +9,15 @@ module Simplex.Chat.Types where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) -import Database.SQLite.Simple.FromField (FromField (..)) +import Data.Typeable (Typeable) +import Database.SQLite.Simple (ResultError (..), SQLData (..)) +import Database.SQLite.Simple.FromField (FieldParser, FromField (..), returnError) +import Database.SQLite.Simple.Internal (Field (..)) +import Database.SQLite.Simple.Ok (Ok (Ok)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics import Simplex.Messaging.Agent.Protocol (ConnId) @@ -19,6 +25,7 @@ import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) data User = User { userId :: UserId, + userContactId :: Int64, localContactRef :: ContactRef, profile :: Profile, activeUser :: Bool @@ -26,23 +33,27 @@ data User = User type UserId = Int64 -data Contact - = Contact - { contactId :: Int64, - localContactRef :: ContactRef, - profile :: Profile, - activeConn :: Connection - } - | NewContact {activeConn :: Connection} +data Contact = Contact + { contactId :: Int64, + localContactRef :: ContactRef, + profile :: Profile, + activeConn :: Connection + } deriving (Eq, Show) +contactConnId :: Contact -> ConnId +contactConnId Contact {activeConn = Connection {agentConnId}} = agentConnId + type ContactRef = Text type GroupRef = Text data Group = Group { groupId :: Int64, - localGroupRef :: Text + localGroupRef :: Text, + groupProfile :: GroupProfile, + members :: [(GroupMember, Connection)], + membership :: GroupMember } deriving (Eq, Show) @@ -66,6 +77,84 @@ instance ToJSON GroupProfile where toEncoding = J.genericToEncoding J.defaultOpt instance FromJSON GroupProfile +data GroupMember = GroupMember + { groupMemberId :: Int64, + memberId :: MemberId, + memberRole :: GroupMemberRole, + memberStatus :: GroupMemberStatus, + invitedBy :: InvitedBy, + memberProfile :: Profile, + memberContactId :: Maybe Int64 + } + deriving (Eq, Show) + +type MemberId = ByteString + +data InvitedBy = IBContact Int64 | IBUser | IBUnknown + deriving (Eq, Show) + +toInvitedBy :: Int64 -> Maybe Int64 -> InvitedBy +toInvitedBy userCtId (Just ctId) + | userCtId == ctId = IBUser + | otherwise = IBContact ctId +toInvitedBy _ Nothing = IBUnknown + +fromInvitedBy :: Int64 -> InvitedBy -> Maybe Int64 +fromInvitedBy userCtId = \case + IBUnknown -> Nothing + IBContact ctId -> Just ctId + IBUser -> Just userCtId + +data GroupMemberRole = GRMember | GRAdmin | GROwner + deriving (Eq, Show, Ord) + +instance FromField GroupMemberRole where fromField = fromBlobField_ toMemberRole + +instance ToField GroupMemberRole where toField = toField . serializeMemberRole + +toMemberRole :: ByteString -> Either String GroupMemberRole +toMemberRole = \case + "owner" -> Right GROwner + "admin" -> Right GRAdmin + "member" -> Right GRMember + r -> Left $ "invalid group member role " <> B.unpack r + +serializeMemberRole :: GroupMemberRole -> ByteString +serializeMemberRole = \case + GROwner -> "owner" + GRAdmin -> "admin" + GRMember -> "member" + +fromBlobField_ :: Typeable k => (ByteString -> Either String k) -> FieldParser k +fromBlobField_ p = \case + f@(Field (SQLBlob b) _) -> + case p b of + Right k -> Ok k + Left e -> returnError ConversionFailed f ("couldn't parse field: " ++ e) + f -> returnError ConversionFailed f "expecting SQLBlob column type" + +data GroupMemberStatus = GSMemInvited | GSMemAccepted | GSMemConnected | GSMemReady + deriving (Eq, Show) + +instance FromField GroupMemberStatus where fromField = fromTextField_ memberStatusT + +instance ToField GroupMemberStatus where toField = toField . serializeMemberStatus + +memberStatusT :: Text -> Maybe GroupMemberStatus +memberStatusT = \case + "invited" -> Just GSMemInvited + "accepted" -> Just GSMemAccepted + "connected" -> Just GSMemConnected + "ready" -> Just GSMemReady + _ -> Nothing + +serializeMemberStatus :: GroupMemberStatus -> Text +serializeMemberStatus = \case + GSMemInvited -> "invited" + GSMemAccepted -> "accepted" + GSMemConnected -> "connected" + GSMemReady -> "ready" + data Connection = Connection { connId :: Int64, agentConnId :: ConnId, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 0a34d67c4a..bb7cc01ec7 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,6 +1,8 @@ {-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.View @@ -12,6 +14,7 @@ module Simplex.Chat.View showContactDisconnected, showReceivedMessage, showSentMessage, + showGroupCreated, safeDecodeUtf8, ) where @@ -26,6 +29,7 @@ import Data.Time.Format (defaultTimeLocale, formatTime) import Data.Time.LocalTime (TimeZone, ZonedTime, getCurrentTimeZone, getZonedTime, localDay, localTimeOfDay, timeOfDayToTime, utcToLocalTime, zonedTimeToLocalTime) import Simplex.Chat.Controller import Simplex.Chat.Markdown +import Simplex.Chat.Store (StoreError (..)) import Simplex.Chat.Styled import Simplex.Chat.Terminal (printToTerminal) import Simplex.Chat.Types @@ -56,11 +60,14 @@ showReceivedMessage c utcTime msg mOk = printToView =<< liftIO (receivedMessage showSentMessage :: ChatReader m => ContactRef -> ByteString -> m () showSentMessage c msg = printToView =<< liftIO (sentMessage c msg) +showGroupCreated :: ChatReader m => GroupProfile -> m () +showGroupCreated = printToView . groupCreated + invitation :: SMPQueueInfo -> [StyledString] invitation qInfo = [ "pass this invitation to your contact (via another channel): ", "", - (bPlain . serializeSmpQueueInfo) qInfo, + (plain . serializeSmpQueueInfo) qInfo, "", "and ask them to connect: /c " ] @@ -74,6 +81,9 @@ contactConnected c = [ttyContact c <> " is connected"] contactDisconnected :: ContactRef -> [StyledString] contactDisconnected c = ["disconnected from " <> ttyContact c <> " - restart chat"] +groupCreated :: GroupProfile -> [StyledString] +groupCreated GroupProfile {groupRef, displayName} = ["group " <> ttyGroup groupRef <> " (" <> plain displayName <> ") is created"] + receivedMessage :: ContactRef -> UTCTime -> Text -> MsgIntegrity -> IO [StyledString] receivedMessage c utcTime msg mOk = do t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime @@ -114,9 +124,11 @@ msgPlain = map styleMarkdownText . T.lines chatError :: ChatError -> [StyledString] chatError = \case - ChatErrorContact e -> case e of - CENotFound c -> ["no contact " <> ttyContact c] - CEProfile s -> ["invalid profile: " <> plain s] + ChatErrorStore err -> case err of + SEContactNotFound c -> ["no contact " <> ttyContact c] + SEContactNotReady c -> ["contact " <> ttyContact c <> " is not active yet"] + SEDuplicateGroupRef -> ["group with this alias already exists"] + e -> ["chat db error: " <> plain (show e)] ChatErrorAgent err -> case err of -- CONN e -> case e of -- -- TODO replace with ChatErrorContact errors, these errors should never happen @@ -138,8 +150,8 @@ ttyToContact c = styled (Colored Cyan) $ "@" <> c <> " " ttyFromContact :: ContactRef -> StyledString ttyFromContact c = styled (Colored Yellow) $ c <> "> " --- ttyGroup :: Group -> StyledString --- ttyGroup (Group g) = styled (Colored Blue) $ "#" <> g +ttyGroup :: GroupRef -> StyledString +ttyGroup g = styled (Colored Blue) $ "#" <> g -- ttyFromGroup :: Group -> Contact -> StyledString -- ttyFromGroup (Group g) (Contact a) = styled (Colored Yellow) $ "#" <> g <> " " <> a <> "> " diff --git a/src/Simplex/Chat/protocol.md b/src/Simplex/Chat/protocol.md index 9821f108f5..d363800b48 100644 --- a/src/Simplex/Chat/protocol.md +++ b/src/Simplex/Chat/protocol.md @@ -69,13 +69,15 @@ refMsgHash = 16*16(OCTET) ; SHA256 of agent message body ### Group protocol -A -> B: invite to group - `MSG: x.grp.inv G_INV_ID,G_MEM_ID_B,G_MEM_ROLE x.json:NNN ` +#### Add group member + +A -> B: invite to group - `MSG: x.grp.inv G_MEM_ID_A,G_MEM_ROLE_A,G_MEM_ID_B,G_MEM_ROLE_B, x.json:NNN ` user B confirms -B -> A: join group - `MSG: x.grp.acpt G_INV_ID,` -A -> Bg: establish group connection (A: JOIN, B: LET) -A -> group (including B)): announce group member: `MSG: N x.grp.mem.new G_MEM_ID_B,G_MEM_ROLE x.json:NNN ` +B -> A: establish group connection (B: JOIN, A: LET) +B -> Ag: join group - `in SMP confirmation: x.grp.acpt G_MEM_ID_B` +A -> group (including B)): announce group member: `MSG: N x.grp.mem.new G_MEM_ID_B,G_MEM_ROLE_B x.json:NNN ` subsequent messages between A and B are via group connection -A -> Bg: intro member - `MSG: x.grp.mem.intro G_MEM_ID_M,G_MEM_ROLE x.json:NNN ` +A -> Bg: intro member - `MSG: x.grp.mem.intro G_MEM_ID_M,G_MEM_ROLE_M x.json:NNN ` B -> Ag: inv for mem - `MSG: x.grp.mem.inv G_MEM_ID_M,,,` M is an existing member, messages are via group connection A -> Mg: fwd inv - `MSG: x.grp.mem.fwd G_MEM_ID_B,,,` @@ -91,3 +93,7 @@ M -> Ag: connected to M: `MSG: x.grp.mem.con G_MEM_ID_B` once all members connected A -> group: `MSG: N x.grp.mem.ok G_MEM_ID_B` + +#### Send group message + +`MSG: N x.msg.new G_MEM_ROLE, x.json:NNN ` diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 6752966064..95947f1ceb 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -20,7 +20,7 @@ bobProfile = Profile {contactRef = "bob", displayName = "Bob"} testAddContact :: Spec testAddContact = describe "add chat contact" $ - xit "add contact and send/receive message" $ + it "add contact and send/receive message" $ testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/a" Just inv <- invitation <$> getWindow alice diff --git a/tests/Test.hs b/tests/Test.hs index f9c0b770e0..990d86ca0b 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -10,5 +10,5 @@ main = do hspec $ do describe "SimpleX chat markdown" markdownTests describe "SimpleX chat protocol" protocolTests - describe "SimpleX chat client" testAddContact + xdescribe "SimpleX chat client" testAddContact removeDirectoryRecursive "tests/tmp"