From 5243613045f4c12f0ec5b0b01393723e91f08fa5 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Thu, 3 Nov 2022 14:46:36 +0400 Subject: [PATCH] core: group link connection request uri data; automatically join groups over group links (#1275) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 54 ++++--- src/Simplex/Chat/Call.hs | 9 +- src/Simplex/Chat/Messages.hs | 4 +- .../Migrations/M20221029_group_link_id.hs | 14 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 2 + src/Simplex/Chat/Protocol.hs | 4 +- src/Simplex/Chat/Store.hs | 90 +++++++---- src/Simplex/Chat/Types.hs | 46 +++++- tests/ChatTests.hs | 149 ++++-------------- tests/ProtocolTests.hs | 5 +- 11 files changed, 198 insertions(+), 180 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20221029_group_link_id.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 2ae7671e7..d5f7ca71a 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -60,6 +60,7 @@ library Simplex.Chat.Migrations.M20221021_auto_accept__group_links Simplex.Chat.Migrations.M20221024_contact_used Simplex.Chat.Migrations.M20221025_chat_settings + Simplex.Chat.Migrations.M20221029_group_link_id Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.ProfileGenerator diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 1efedbe4c..b49776508 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -986,8 +986,10 @@ processChatCommand = \case when (userRole < GRAdmin) $ throwChatError CEGroupUserRole when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo) unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact Nothing - withStore $ \db -> createGroupLink db user gInfo connId cReq + groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) + let crClientData = encodeJson $ CRGroupData groupLinkId + (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact $ Just crClientData + withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId pure $ CRGroupLinkCreated gInfo cReq APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do gInfo <- withStore $ \db -> getGroupInfo db user groupId @@ -1107,7 +1109,7 @@ processChatCommand = \case CTGroup -> withStore $ \db -> getGroupChatItemIdByText db user cId (Just localDisplayName) (safeDecodeUtf8 msg) _ -> throwChatError $ CECommandError "not supported" connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse - connectViaContact userId cReq profile = withChatLock "connectViaContact" $ do + connectViaContact userId cReq@(CRContactUri ConnReqUriData {crClientData}) profile = withChatLock "connectViaContact" $ do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq withStore' (\db -> getConnReqContactXContactId db userId cReqHash) >>= \case (Just contact, _) -> pure $ CRContactAlreadyExists contact @@ -1123,7 +1125,8 @@ processChatCommand = \case incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = fromMaybe profile incognitoProfile connId <- withAgent $ \a -> joinConnection a True cReq $ directMessage (XContact profileToSend $ Just xContactId) - conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile + let groupLinkId = crClientData >>= decodeJson >>= \(CRGroupData gli) -> Just gli + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId toView $ CRNewContactConnection conn pure $ CRSentInvitation incognitoProfile contactMember :: Contact -> [GroupMember] -> Maybe GroupMember @@ -1211,7 +1214,7 @@ processChatCommand = \case sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> m () sendGrpInvitation user ct@Contact {localDisplayName} GroupInfo {groupId, groupProfile, membership} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do let GroupMember {memberRole = userRole, memberId = userMemberId} = membership - groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile + groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile Nothing (msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd ct) msg content Nothing Nothing @@ -1744,7 +1747,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore $ \db -> createNewContactMemberAsync db gVar user groupId ct GRMember groupConnIds probeMatchingContacts ct $ contactConnIncognito ct _ -> pure () - Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> do + Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m let connectedIncognito = contactConnIncognito ct || memberIncognito membership @@ -1797,13 +1800,14 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = Nothing -> messageError "implementation error: invitee does not have contact" Just ct -> do withStore' $ \db -> setNewContactMemberConnRequest db user m cReq - sendGrpInvitation ct m + groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo + sendGrpInvitation ct m groupLinkId toView $ CRSentGroupInvitation gInfo ct m where - sendGrpInvitation :: Contact -> GroupMember -> m () - sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} = do + sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> m () + sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do let GroupMember {memberRole = userRole, memberId = userMemberId} = membership - groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile + groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile groupLinkId (_msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv -- we could link chat item with sent group invitation message (_msg) createInternalChatItem (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing @@ -2416,18 +2420,32 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () - processGroupInvitation ct@Contact {localDisplayName = c, activeConn = Connection {customUserProfileId}} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole)} msg msgMeta = do + processGroupInvitation ct@Contact {localDisplayName = c, activeConn = Connection {connId, customUserProfileId}} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} msg msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta when (fromRole < GRMember || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile - gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = GroupMember {groupMemberId}} <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId - let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content Nothing - withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - toView $ CRReceivedGroupInvitation gInfo ct memRole - showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" + (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId + groupLinkId' <- withStore' $ \db -> getConnectionGroupLinkId db user connId + if sameGroupLinkId groupLinkId groupLinkId' + then do + connIds <- joinAgentConnectionAsync user True connRequest . directMessage $ XGrpAcpt memberId + withStore' $ \db -> do + createMemberConnectionAsync db user hostId connIds + updateGroupMemberStatusById db userId hostId GSMemAccepted + updateGroupMemberStatus db userId membership GSMemAccepted + toView $ CRUserAcceptedGroupSent gInfo {membership = membership {memberStatus = GSMemAccepted}} + else do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole + ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content Nothing + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + toView $ CRReceivedGroupInvitation gInfo ct memRole + showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" + where + sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool + sameGroupLinkId (Just gli) (Just gli') = gli == gli' + sameGroupLinkId _ _ = False checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> m () checkIntegrityCreateItem cd MsgMeta {integrity, broker = (_, brokerTs)} = case integrity of diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 784d602ed..bdf0a05f7 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -15,16 +15,13 @@ import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) -import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Text (Text) -import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) -import Simplex.Chat.Types (Contact, ContactId) -import Simplex.Messaging.Util (safeDecodeUtf8) +import Simplex.Chat.Types (Contact, ContactId, decodeJson, encodeJson) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) @@ -103,10 +100,10 @@ instance ToJSON CallState where toEncoding = J.genericToEncoding $ singleFieldJSON fstToLower instance ToField CallState where - toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode + toField = toField . encodeJson instance FromField CallState where - fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8 + fromField = fromTextField_ decodeJson newtype CallId = CallId ByteString deriving (Eq, Show) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 4fe1ee447..1f91e1f12 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -726,7 +726,7 @@ msgDirToDeletedContent_ msgDir mode = case msgDir of -- platform independent instance ToField (CIContent d) where - toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode . dbJsonCIContent + toField = toField . encodeJson . dbJsonCIContent -- platform specific instance ToJSON (CIContent d) where @@ -742,7 +742,7 @@ instance FromJSON ACIContent where parseJSON = fmap aciContentJSON . J.parseJSON -- platform independent -instance FromField ACIContent where fromField = fromTextField_ $ fmap aciContentDBJSON . J.decode . LB.fromStrict . encodeUtf8 +instance FromField ACIContent where fromField = fromTextField_ $ fmap aciContentDBJSON . decodeJson -- platform specific data JSONCIContent diff --git a/src/Simplex/Chat/Migrations/M20221029_group_link_id.hs b/src/Simplex/Chat/Migrations/M20221029_group_link_id.hs new file mode 100644 index 000000000..da290e415 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20221029_group_link_id.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20221029_group_link_id where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20221029_group_link_id :: Query +m20221029_group_link_id = + [sql| +ALTER TABLE user_contact_links ADD COLUMN group_link_id BLOB; + +ALTER TABLE connections ADD COLUMN group_link_id BLOB; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 31b29343f..b4c1d7676 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -257,6 +257,7 @@ CREATE TABLE connections( conn_req_inv BLOB, local_alias DEFAULT '' CHECK(local_alias NOT NULL), via_group_link INTEGER DEFAULT 0 CHECK(via_group_link NOT NULL), + group_link_id BLOB, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -273,6 +274,7 @@ CREATE TABLE user_contact_links( auto_reply_msg_content TEXT DEFAULT NULL, group_id INTEGER REFERENCES groups ON DELETE CASCADE, auto_accept_incognito INTEGER DEFAULT 0 CHECK(auto_accept_incognito NOT NULL), + group_link_id BLOB, UNIQUE(user_id, local_display_name) ); CREATE TABLE contact_requests( diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 157acf30d..bbe9b6f40 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -391,10 +391,10 @@ instance ToJSON MsgContent where MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t instance ToField MsgContent where - toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode + toField = toField . encodeJson instance FromField MsgContent where - fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8 + fromField = fromTextField_ decodeJson data CMEventTag (e :: MsgEncoding) where XMsgNew_ :: CMEventTag 'Json diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 4e4db3124..c99c94e10 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -58,6 +58,8 @@ module Simplex.Chat.Store getGroupLinkConnection, deleteGroupLink, getGroupLink, + getGroupLinkId, + getConnectionGroupLinkId, createOrUpdateContactRequest, getContactRequest, getContactRequestIdByName, @@ -97,6 +99,7 @@ module Simplex.Chat.Store setNewContactMemberConnRequest, getMemberInvitation, createMemberConnection, + createMemberConnectionAsync, updateGroupMemberStatus, updateGroupMemberStatusById, createNewGroupMember, @@ -244,7 +247,7 @@ import Data.Either (rights) import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, sortBy, sortOn) +import Data.List (sortBy, sortOn) import Data.List.NonEmpty (NonEmpty) import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe) import Data.Ord (Down (..)) @@ -292,6 +295,7 @@ import Simplex.Chat.Migrations.M20221019_unread_chat import Simplex.Chat.Migrations.M20221021_auto_accept__group_links import Simplex.Chat.Migrations.M20221024_contact_used import Simplex.Chat.Migrations.M20221025_chat_settings +import Simplex.Chat.Migrations.M20221029_group_link_id import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (ACorrId, AgentMsgId, ConnId, InvitationId, MsgMeta (..)) @@ -337,7 +341,8 @@ schemaMigrations = ("20221019_unread_chat", m20221019_unread_chat), ("20221021_auto_accept__group_links", m20221021_auto_accept__group_links), ("20221024_contact_used", m20221024_contact_used), - ("20221025_chat_settings", m20221025_chat_settings) + ("20221025_chat_settings", m20221025_chat_settings), + ("20221029_group_link_id", m20221029_group_link_id) ] -- | The list of migrations in ascending order by date @@ -414,8 +419,8 @@ setActiveUser db userId = do DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId incognitoProfile = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile let pccConnStatus = ConnJoined @@ -424,10 +429,10 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile = do [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, - via_contact_uri_hash, xcontact_id, custom_user_profile_id, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?) + via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId, customUserProfileId, createdAt, createdAt) + ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt)) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -895,16 +900,16 @@ updateUserAddressAutoAccept db userId autoAccept = do Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply) _ -> (False, False, Nothing) -createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> ExceptT StoreError IO () -createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq = +createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> ExceptT StoreError IO () +createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId cReq groupLinkId = checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do currentTs <- getCurrentTime DB.execute db - "INSERT INTO user_contact_links (user_id, group_id, local_display_name, conn_req_contact, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (userId, groupId, "group_link_" <> localDisplayName, cReq, True, currentTs, currentTs) - groupLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just groupLinkId) agentConnId Nothing Nothing Nothing 0 currentTs + "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, True, currentTs, currentTs) + userContactLinkId <- insertedRowId db + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing Nothing 0 currentTs getGroupLinkConnection :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO Connection getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = @@ -963,7 +968,17 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO ConnReqContact getGroupLink db User {userId} gInfo@GroupInfo {groupId} = ExceptT . firstRow fromOnly (SEGroupLinkNotFound gInfo) $ - DB.query db "SELECT conn_req_contact FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId) + DB.query db "SELECT conn_req_contact FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) + +getGroupLinkId :: DB.Connection -> User -> GroupInfo -> IO (Maybe GroupLinkId) +getGroupLinkId db User {userId} GroupInfo {groupId} = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) + +getConnectionGroupLinkId :: DB.Connection -> User -> Int64 -> IO (Maybe GroupLinkId) +getConnectionGroupLinkId db User {userId} connId = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT group_link_id FROM connections WHERE user_id = ? AND connection_id = ? LIMIT 1" (userId, connId) createOrUpdateContactRequest :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayName, fullName, image, preferences} xContactId_ = @@ -1597,25 +1612,28 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs} -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO GroupInfo +createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db user gId + hostId <- getHostMemberId_ db user gId let GroupMember {groupMemberId, memberId, memberRole} = membership MemberIdRole {memberId = memberId', memberRole = memberRole'} = invitedMember liftIO . when (memberId /= memberId' || memberRole /= memberRole') $ DB.execute db "UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ?" (memberId', memberRole', groupMemberId) - if p' == groupProfile - then pure gInfo - else updateGroupProfile db user gInfo groupProfile + gInfo' <- + if p' == groupProfile + then pure gInfo + else updateGroupProfile db user gInfo groupProfile + pure (gInfo', hostId) where getInvitationGroupId_ :: IO (Maybe Int64) getInvitationGroupId_ = maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) - createGroupInvitation_ :: ExceptT StoreError IO GroupInfo + createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation_ = do let GroupProfile {displayName, fullName, image, preferences} = groupProfile ExceptT $ @@ -1632,10 +1650,15 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs) insertedRowId db - _ <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs let chatSettings = ChatSettings {enableNtfs = True} - pure GroupInfo {groupId, localDisplayName, groupProfile, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs} + pure (GroupInfo {groupId, localDisplayName, groupProfile, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs}, groupMemberId) + +getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId +getHostMemberId_ db User {userId} groupId = + ExceptT . firstRow fromOnly (SEHostMemberIdNotFound groupId) $ + DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ?" (userId, groupId, GCHostMember) createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> ExceptT StoreError IO GroupMember createContactMemberInv_ db User {userId, userContactId} groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt = do @@ -1845,15 +1868,14 @@ toContactMember :: User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember toContactMember User {userContactId} (memberRow :. connRow) = (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection connRow} --- TODO no need to load all members to find the member who invited the user, --- instead of findFromContact there could be a query getGroupInvitation :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation -getGroupInvitation db user groupId = do - cReq <- getConnRec_ user - Group groupInfo@GroupInfo {membership} members <- getGroup db user groupId - when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined - case (cReq, findFromContact (invitedBy membership) members) of - (Just connRequest, Just fromMember) -> +getGroupInvitation db user groupId = + getConnRec_ user >>= \case + Just connRequest -> do + groupInfo@GroupInfo {membership} <- getGroupInfo db user groupId + when (memberStatus membership /= GSMemInvited) $ throwError SEGroupAlreadyJoined + hostId <- getHostMemberId_ db user groupId + fromMember <- getGroupMember db user groupId hostId pure ReceivedGroupInvitation {fromMember, connRequest, groupInfo} _ -> throwError SEGroupInvitationNotFound where @@ -1861,9 +1883,6 @@ getGroupInvitation db user groupId = do getConnRec_ User {userId} = ExceptT $ do firstRow fromOnly (SEGroupNotFound groupId) $ DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?" (groupId, userId) - findFromContact :: InvitedBy -> [GroupMember] -> Maybe GroupMember - findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId) - findFromContact _ = const Nothing type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, LocalAlias, Maybe ChatPreferences)) @@ -1982,6 +2001,12 @@ createMemberConnection db userId GroupMember {groupMemberId} agentConnId = do currentTs <- getCurrentTime void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 currentTs +createMemberConnectionAsync :: DB.Connection -> User -> GroupMemberId -> (CommandId, ConnId) -> IO () +createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentConnId) = do + currentTs <- getCurrentTime + Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 currentTs + setCommandConnId db user cmdId connId + updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () updateGroupMemberStatus db userId GroupMember {groupMemberId} = updateGroupMemberStatusById db userId groupMemberId @@ -4559,6 +4584,7 @@ data StoreError | SEProfileNotFound {profileId :: Int64} | SEDuplicateGroupLink {groupInfo :: GroupInfo} | SEGroupLinkNotFound {groupInfo :: GroupInfo} + | SEHostMemberIdNotFound {groupId :: Int64} deriving (Show, Exception, Generic) instance ToJSON StoreError where diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 1ebb122c3..86bd6526d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -42,8 +42,8 @@ import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON) -import Simplex.Messaging.Util ((<$?>), safeDecodeUtf8) +import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) class IsContact a where contactId' :: a -> ContactId @@ -252,10 +252,10 @@ instance ToJSON ChatPreferences where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} instance ToField ChatPreferences where - toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode + toField = toField . encodeJson instance FromField ChatPreferences where - fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8 + fromField = fromTextField_ decodeJson data Preference = Preference {enable :: PrefSwitch} @@ -360,11 +360,41 @@ instance ToField ImageData where toField (ImageData t) = toField t instance FromField ImageData where fromField = fmap ImageData . fromField +data CReqClientData = CRGroupData {groupLinkId :: GroupLinkId} + deriving (Generic) + +instance ToJSON CReqClientData where + toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "CD" + toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "CD" + +instance FromJSON CReqClientData where + parseJSON = J.genericParseJSON . taggedObjectJSON $ dropPrefix "CD" + +newtype GroupLinkId = GroupLinkId {unGroupLinkId :: ByteString} -- used to identify invitation via group link + deriving (Eq, Show) + +instance FromField GroupLinkId where fromField f = GroupLinkId <$> fromField f + +instance ToField GroupLinkId where toField (GroupLinkId g) = toField g + +instance StrEncoding GroupLinkId where + strEncode (GroupLinkId g) = strEncode g + strDecode s = GroupLinkId <$> strDecode s + strP = GroupLinkId <$> strP + +instance FromJSON GroupLinkId where + parseJSON = strParseJSON "GroupLinkId" + +instance ToJSON GroupLinkId where + toJSON = strToJSON + toEncoding = strToJEncoding + data GroupInvitation = GroupInvitation { fromMember :: MemberIdRole, invitedMember :: MemberIdRole, connRequest :: ConnReqInvitation, - groupProfile :: GroupProfile + groupProfile :: GroupProfile, + groupLinkId :: Maybe GroupLinkId } deriving (Eq, Show, Generic, FromJSON) @@ -1133,3 +1163,9 @@ data XGrpMemIntroCont = XGrpMemIntroCont groupConnReq :: ConnReqInvitation } deriving (Show) + +encodeJson :: ToJSON a => a -> Text +encodeJson = safeDecodeUtf8 . LB.toStrict . J.encode + +decodeJson :: FromJSON a => Text -> Maybe a +decodeJson = J.decode . LB.fromStrict . encodeUtf8 diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index c0ec74c75..4733efa00 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -107,7 +107,7 @@ chatTests = do it "accept contact request incognito" testAcceptContactRequestIncognito it "join group incognito" testJoinGroupIncognito it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito - it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito + it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito describe "contact aliases and prefs" $ do it "set contact alias" testSetAlias it "set connection alias" testSetConnectionAlias @@ -142,7 +142,6 @@ chatTests = do it "create group link, join via group link" testGroupLink it "sending message to contact created via group link marks it used" testGroupLinkContactUsed it "create group link, join via group link - incognito membership" testGroupLinkIncognitoMembership - it "deleting invited member does not leave broken chat item" testGroupLinkDeleteInvitedMemberNoBrokenItem describe "queue rotation" $ do it "switch contact to a different queue" testSwitchContact it "switch group member to a different queue" testSwitchGroupMember @@ -2693,7 +2692,6 @@ testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ -- bob doesn't receive invitation (bob do @@ -2733,7 +2731,7 @@ testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathPr threadDelay 1000000 cath ##> "/_set prefs @2 {}" cath <## "preferences were updated: contact's voice messages are on, user's voice messages are unset" - + testSetAlias :: IO () testSetAlias = testChat2 aliceProfile bobProfile $ \alice bob -> do @@ -3403,23 +3401,21 @@ testGroupLink = concurrentlyN_ [ do alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link", + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", do bob <## "alice (Alice): contact is connected" - bob <## "#team: alice invites you to join the group as member" - bob <## "use /j team to accept" + bob <## "#team: you joined the group" ] - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link")]) - alice @@@ [("#team", "invited via your group link")] -- contacts connected via group link are not in chat previews + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + -- contacts connected via group link are not in chat previews + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] -- calling /_get chat api marks it as used and adds it to chat previews alice #$> ("/_get chat @2 count=100", chat, []) - alice @@@ [("@bob", ""), ("#team", "invited via your group link")] + alice @@@ [("@bob", ""), ("#team", "connected")] alice <##> bob - alice @@@ [("@bob", "hey"), ("#team", "invited via your group link")] - bob ##> "/j team" - concurrently_ - (alice <## "#team: bob joined the group") - (bob <## "#team: you joined the group") + alice @@@ [("@bob", "hey"), ("#team", "connected")] -- user address doesn't interfere alice ##> "/ad" @@ -3443,18 +3439,12 @@ testGroupLink = alice <## "cath_1 (Catherine): contact is connected" alice <## "cath_1 invited to group #team via your group link" alice <## "contact cath_1 is merged into cath" - alice <## "use @cath to send messages", + alice <## "use @cath to send messages" + alice <## "#team: cath joined the group", do cath <## "alice_1 (Alice): contact is connected" cath <## "contact alice_1 is merged into alice" cath <## "use @alice to send messages" - cath <## "#team: alice invites you to join the group as member" - cath <## "use /j team to accept" - ] - cath ##> "/j team" - concurrentlyN_ - [ alice <## "#team: cath joined the group", - do cath <## "#team: you joined the group" cath <## "#team: member bob (Bob) is connected", do @@ -3507,18 +3497,21 @@ testGroupLinkContactUsed = concurrentlyN_ [ do alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link", + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", do bob <## "alice (Alice): contact is connected" - bob <## "#team: alice invites you to join the group as member" - bob <## "use /j team to accept" + bob <## "#team: you joined the group" ] - -- sending message to contact marks it as used - alice @@@ [("#team", "invited via your group link")] + -- sending/receiving a message marks contact as used + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] alice #> "@bob hello" bob <# "alice> hello" alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") - alice @@@ [("@bob", ""), ("#team", "invited via your group link")] + alice @@@ [("@bob", ""), ("#team", "connected")] + bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") + bob @@@ [("@alice", ""), ("#team", "connected")] testGroupLinkIncognitoMembership :: IO () testGroupLinkIncognitoMembership = @@ -3565,26 +3558,20 @@ testGroupLinkIncognitoMembership = [ do bob <## ("cath (Catherine): contact is connected, your incognito profile for this contact is " <> bobIncognito) bob <## "use /info cath to print out this incognito profile again" - bob <## "cath invited to group #team via your group link", + bob <## "cath invited to group #team via your group link" + bob <## "#team: cath joined the group", do cath <## (bobIncognito <> ": contact is connected") - cath <## ("#team: " <> bobIncognito <> " invites you to join the group as member") - cath <## "use /j team to accept" - ] - bob ?#> "@cath hi, I'm incognito" - cath <# (bobIncognito <> "> hi, I'm incognito") - cath #> ("@" <> bobIncognito <> " hey, I'm cath") - bob ?<# "cath> hey, I'm cath" - cath ##> "/j team" - concurrentlyN_ - [ bob <## "#team: cath joined the group", - do cath <## "#team: you joined the group" cath <## "#team: member alice (Alice) is connected", do alice <## ("#team: " <> bobIncognito <> " added cath (Catherine) to the group (connecting...)") alice <## "#team: new member cath is connected" ] + bob ?#> "@cath hi, I'm incognito" + cath <# (bobIncognito <> "> hi, I'm incognito") + cath #> ("@" <> bobIncognito <> " hey, I'm cath") + bob ?<# "cath> hey, I'm cath" -- dan joins incognito dan #$> ("/incognito on", id, "ok") dan ##> ("/c " <> gLink) @@ -3597,22 +3584,11 @@ testGroupLinkIncognitoMembership = [ do bob <## (danIncognito <> ": contact is connected, your incognito profile for this contact is " <> bobIncognito) bob <## ("use /info " <> danIncognito <> " to print out this incognito profile again") - bob <## (danIncognito <> " invited to group #team via your group link"), + bob <## (danIncognito <> " invited to group #team via your group link") + bob <## ("#team: " <> danIncognito <> " joined the group"), do dan <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> danIncognito) dan <## ("use /info " <> bobIncognito <> " to print out this incognito profile again") - dan <## ("#team: " <> bobIncognito <> " invites you to join the group as member") - dan <## ("use /j team to join incognito as " <> danIncognito) - ] - dan #$> ("/incognito off", id, "ok") - bob ?#> ("@" <> danIncognito <> " hi, I'm incognito") - dan ?<# (bobIncognito <> "> hi, I'm incognito") - dan ?#> ("@" <> bobIncognito <> " hey, me too") - bob ?<# (danIncognito <> "> hey, me too") - dan ##> "/j team" - concurrentlyN_ - [ bob <## ("#team: " <> danIncognito <> " joined the group"), - do dan <## ("#team: you joined the group incognito as " <> danIncognito) dan <### [ "#team: member alice (Alice) is connected", @@ -3625,6 +3601,11 @@ testGroupLinkIncognitoMembership = cath <## ("#team: " <> bobIncognito <> " added " <> danIncognito <> " to the group (connecting...)") cath <## ("#team: new member " <> danIncognito <> " is connected") ] + dan #$> ("/incognito off", id, "ok") + bob ?#> ("@" <> danIncognito <> " hi, I'm incognito") + dan ?<# (bobIncognito <> "> hi, I'm incognito") + dan ?#> ("@" <> bobIncognito <> " hey, me too") + bob ?<# (danIncognito <> "> hey, me too") alice #> "#team hello" concurrentlyN_ [ bob ?<# "#team alice> hello", @@ -3650,66 +3631,6 @@ testGroupLinkIncognitoMembership = cath <# ("#team " <> danIncognito <> "> how is it going?") ] -testGroupLinkDeleteInvitedMemberNoBrokenItem :: IO () -testGroupLinkDeleteInvitedMemberNoBrokenItem = - testChat2 aliceProfile bobProfile $ - \alice bob -> do - alice ##> "/g team" - alice <## "group #team is created" - alice <## "use /a team to add members" - alice ##> "/create link #team" - gLink <- getGroupLink alice "team" True - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link", - do - bob <## "alice (Alice): contact is connected" - bob <## "#team: alice invites you to join the group as member" - bob <## "use /j team to accept" - ] - alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link")]) - alice @@@ [("#team", "invited via your group link")] - -- removing invited member who connected via group link does not leave broken chat item - alice ##> "/rm team bob" - alice <## "#team: you removed bob from the group" - alice #$> ("/_get chat #1 count=100", chat, []) - alice @@@ [("#team", "")] - -- removing member deletes unused group contact - alice ##> "@bob hi" - alice <## "no contact bob" - (bob "/j team" - bob <## "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" - -- repeat request is prohibited because of the re-used XContactId, until contact is deleted - bob ##> ("/c " <> gLink) - bob <## "alice (Alice): contact already exists" - bob ##> "/d alice" - bob <## "alice: contact is deleted" - bob ##> ("/c " <> gLink) - bob <## "connection request sent!" - alice <## "bob (Bob): accepting request to join group #team..." - concurrentlyN_ - [ do - alice <## "bob (Bob): contact is connected" - alice <## "bob invited to group #team via your group link", - do - bob <## "alice_1 (Alice): contact is connected" - bob <## "#team_1 (team): alice_1 invites you to join the group as member" - bob <## "use /j team_1 to accept" - ] - bob ##> "/j team_1" - concurrently_ - (alice <## "#team: bob joined the group") - (bob <## "#team_1: you joined the group") - alice #> "#team hello" - bob <# "#team_1 alice_1> hello" - bob #> "#team_1 hi there" - alice <# "#team bob> hi there" - testSwitchContact :: IO () testSwitchContact = testChat2 aliceProfile bobProfile $ diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index a40691381..d5ccc6b0a 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -196,7 +196,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ==# XContact testProfile Nothing it "x.grp.inv" $ "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" - #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile} + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing} + it "x.grp.inv with group link id" $ + "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"preferences\":{\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} it "x.grp.acpt without incognito profile" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4")