From f701ffa4e06025280d7784202c51a845040b2374 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:05:49 +0400 Subject: [PATCH] core: communicate group join rejection (#5661) --- src/Simplex/Chat/Controller.hs | 12 ++++ src/Simplex/Chat/Library/Commands.hs | 3 +- src/Simplex/Chat/Library/Internal.hs | 60 ++++++++++++++----- src/Simplex/Chat/Library/Subscriber.hs | 52 ++++++++++------ src/Simplex/Chat/Protocol.hs | 15 ++++- src/Simplex/Chat/Store/Groups.hs | 46 +++++++++----- .../SQLite/Migrations/chat_query_plans.txt | 19 +++++- src/Simplex/Chat/Types.hs | 51 +++++++++++++++- src/Simplex/Chat/View.hs | 7 ++- tests/ChatTests/Groups.hs | 30 ++++++++++ tests/ProtocolTests.hs | 8 +-- 11 files changed, 246 insertions(+), 57 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 5b1712b6b4..e64553af98 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -844,6 +844,12 @@ data ChatResponse | CRAppSettings {appSettings :: AppSettings} | CRTimedAction {action :: String, durationMilliseconds :: Int64} | CRCustomChatResponse {user_ :: Maybe User, response :: Text} + | CRTerminalEvent TerminalEvent + deriving (Show) + +data TerminalEvent + = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} + | TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason} deriving (Show) data DeletedRcvQueue = DeletedRcvQueue @@ -1491,6 +1497,10 @@ chatCmdError user = CRChatCmdError user . ChatError . CECommandError throwChatError :: ChatErrorType -> CM a throwChatError = throwError . ChatError +toViewTE :: TerminalEvent -> CM () +toViewTE = toView . CRTerminalEvent +{-# INLINE toViewTE #-} + -- | Emit local events. toView :: ChatResponse -> CM () toView = lift . toView' @@ -1630,6 +1640,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCSR") ''RemoteCtrlStopReason) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "TE") ''TerminalEvent) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 23f4406b9c..624f6a6dc1 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2621,7 +2621,7 @@ processChatCommand' vr = \case contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> - cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft + cId == Just contactId && s /= GSMemRejected && s /= GSMemRemoved && s /= GSMemLeft checkSndFile :: CryptoFile -> CM Integer checkSndFile (CryptoFile f cfArgs) = do fsFilePath <- lift $ toFSFilePath f @@ -2969,6 +2969,7 @@ processChatCommand' vr = \case (Just gInfo, _) -> groupPlan gInfo where groupPlan gInfo@GroupInfo {membership} + | memberStatus membership == GSMemRejected = pure $ CPGroupLink (GLPKnown gInfo) | not (memberActive membership) && not (memberRemoved membership) = pure $ CPGroupLink (GLPConnectingProhibit $ Just gInfo) | memberActive membership = pure $ CPGroupLink (GLPKnown gInfo) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 3e96e1e496..4a62c4ccb6 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -218,10 +218,11 @@ prepareGroupMsg db user g@GroupInfo {membership} mc mentions quotedItemId_ itemF updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) updatedMentionNames mc ft_ mentions = case ft_ of - Just ft | not (null ft) && not (null mentions) && not (all sameName $ M.assocs mentions) -> - let (mentions', ft') = mapAccumL update M.empty ft - text = T.concat $ map markdownText ft' - in (mc {text} :: MsgContent, Just ft', mentions') + Just ft + | not (null ft) && not (null mentions) && not (all sameName $ M.assocs mentions) -> + let (mentions', ft') = mapAccumL update M.empty ft + text = T.concat $ map markdownText ft' + in (mc {text} :: MsgContent, Just ft', mentions') _ -> (mc, ft_, mentions) where sameName (name, CIMention {memberRef}) = case memberRef of @@ -261,9 +262,10 @@ getCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of getRcvCIMentions :: DB.Connection -> User -> GroupInfo -> Maybe MarkdownList -> Map MemberName MsgMention -> IO (Map MemberName CIMention) getRcvCIMentions db user GroupInfo {groupId} ft_ mentions = case ft_ of - Just ft | not (null ft) && not (null mentions) -> - let mentions' = uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft - in mapM (getMentionedMemberByMemberId db user groupId) mentions' + Just ft + | not (null ft) && not (null mentions) -> + let mentions' = uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft + in mapM (getMentionedMemberByMemberId db user groupId) mentions' _ -> pure M.empty -- prevent "invisible" and repeated-with-different-name mentions @@ -274,8 +276,9 @@ uniqueMsgMentions maxMentions mentions = go M.empty S.empty 0 go acc seen n (name : rest) | n >= maxMentions = acc | otherwise = case M.lookup name mentions of - Just mm@MsgMention {memberId} | S.notMember memberId seen -> - go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest + Just mm@MsgMention {memberId} + | S.notMember memberId seen -> + go (M.insert name mm acc) (S.insert memberId seen) (n + 1) rest _ -> go acc seen n rest getMessageMentions :: DB.Connection -> User -> GroupId -> Text -> IO (Map MemberName GroupMemberId) @@ -827,7 +830,7 @@ acceptGroupJoinRequestAsync gVar <- asks random (groupMemberId, memberId) <- withStore $ \db -> do liftIO $ deleteContactRequestRec db user ucr - createAcceptedMember db gVar user gInfo ucr gLinkMemRole + createJoiningMember db gVar user gInfo ucr gLinkMemRole GSMemAccepted currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let Profile {displayName} = profileToSendOnAccept user incognitoProfile True GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -846,7 +849,34 @@ acceptGroupJoinRequestAsync let chatV = vr `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV withStore $ \db -> do - liftIO $ createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode + liftIO $ createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode + getGroupMemberById db vr user groupMemberId + +acceptGroupJoinSendRejectAsync :: User -> GroupInfo -> UserContactRequest -> GroupRejectionReason -> CM GroupMember +acceptGroupJoinSendRejectAsync + user + gInfo@GroupInfo {groupProfile, membership} + ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} + rejectionReason = do + gVar <- asks random + (groupMemberId, memberId) <- withStore $ \db -> do + liftIO $ deleteContactRequestRec db user ucr + createJoiningMember db gVar user gInfo ucr GRObserver GSMemRejected + let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + msg = + XGrpLinkReject $ + GroupLinkRejection + { fromMember = MemberIdRole userMemberId userRole, + invitedMember = MemberIdRole memberId GRObserver, + groupProfile, + rejectionReason + } + subMode <- chatReadVar subscriptionMode + vr <- chatVersionRange + let chatV = vr `peerConnChatVersion` cReqChatVRange + connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV + withStore $ \db -> do + liftIO $ createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode getGroupMemberById db vr user groupMemberId acceptBusinessJoinRequestAsync :: User -> UserContactRequest -> CM GroupInfo @@ -879,7 +909,7 @@ acceptBusinessJoinRequestAsync subMode <- chatReadVar subscriptionMode let chatV = vr `peerConnChatVersion` cReqChatVRange connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV - withStore' $ \db -> createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode + withStore' $ \db -> createJoiningMemberConnection db user connIds chatV ucr groupMemberId subMode let cd = CDGroupSnd gInfo createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo @@ -1514,7 +1544,7 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do (subtract 1 <$> memIdx_,) $ snd $ foldr' addBody (lastRef, memIdsReqs) mbs where addBody :: Either ChatError a -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) -> (Int, ([GroupMemberId], [Either ChatError ChatMsgReq])) - addBody mb (i, (memIds, reqs)) = + addBody mb (i, (memIds, reqs)) = let req = (conn,msgFlags,) . mkMb memIdx_ i <$> mb in (i - 1, (groupMemberId : memIds, req : reqs)) sndMessageMBR :: Maybe Int -> Int -> SndMessage -> (ValueOrRef MsgBody, [MessageId]) @@ -1542,10 +1572,10 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} members events = do data MemberSendAction = MSASend Connection | MSASendBatched Connection | MSAPending | MSAForwarded memberSendAction :: GroupInfo -> NonEmpty (ChatMsgEvent e) -> [GroupMember] -> GroupMember -> Maybe MemberSendAction -memberSendAction gInfo events members m@GroupMember {memberRole} = case memberConn m of +memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = case memberConn m of Nothing -> pendingOrForwarded Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted -> Nothing + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing | connInactive conn -> Just MSAPending | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn | otherwise -> pendingOrForwarded diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 120e74cb7a..44ad4ccc85 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -22,7 +22,6 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import qualified Data.Aeson as J -import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (lefts, partitionEithers, rights) @@ -764,11 +763,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO update member profile pure () | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" - XInfo _ -> pure () -- sent when connecting via group link + -- sent when connecting via group link + XInfo _ -> + -- TODO [group rejection] Keep rejected member record and connection for ability to start dialogue. + when (memberStatus m == GSMemRejected) $ do + deleteMemberConnection' user m True + withStore' $ \db -> deleteGroupMember db user m XOk -> pure () _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () - CON _pqEnc -> do + CON _pqEnc -> unless (memberStatus m == GSMemRejected) $ do withStore' $ \db -> do updateGroupMemberStatus db userId m GSMemConnected unless (memberActive membership) $ @@ -1291,8 +1295,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> PQSupport -> CM () profileContactRequest invId chatVRange p@Profile {displayName, image} xContactId_ reqPQSup = do - cfg <- asks config - withAllowedName cfg $ withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case + withStore (\db -> createOrUpdateContactRequest db vr user userContactLinkId invId chatVRange p xContactId_ reqPQSup) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORGroup gInfo -> toView $ CRBusinessRequestAlreadyAccepted user gInfo CORRequest cReq -> do @@ -1318,19 +1321,29 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - if v >= groupFastLinkJoinVersion - then do - let useRole = userMemberRole gLinkMemRole $ acceptAsObserver cfg - mem <- acceptGroupJoinRequestAsync user gInfo cReq useRole profileMode - createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing - toView $ CRAcceptingGroupJoinRequestMember user gInfo mem - else messageError "processUserContactRequest: chat version range incompatible for accepting group join request" + cfg <- asks config + case rejectionReason cfg of + Nothing + | v < groupFastLinkJoinVersion -> + messageError "processUserContactRequest: chat version range incompatible for accepting group join request" + | otherwise -> do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + useRole = userMemberRole gLinkMemRole $ acceptAsObserver cfg + mem <- acceptGroupJoinRequestAsync user gInfo cReq useRole profileMode + createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CRAcceptingGroupJoinRequestMember user gInfo mem + Just rjctReason + | v < groupJoinRejectVersion -> + messageWarning $ "processUserContactRequest (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" + | otherwise -> do + mem <- acceptGroupJoinSendRejectAsync user gInfo cReq rjctReason + toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason _ -> toView $ CRReceivedContactRequest user cReq where - withAllowedName ChatConfig {profileNameLimit, allowedProfileName} action - | T.length displayName <= profileNameLimit && maybe True ($ displayName) allowedProfileName = action - | otherwise = liftIO $ putStrLn $ "Joining of " <> T.unpack displayName <> " is blocked" -- TODO send response, maybe event to UI? + rejectionReason ChatConfig {profileNameLimit, allowedProfileName} + | T.length displayName > profileNameLimit = Just GRRLongName + | maybe False (\f -> not $ f displayName) allowedProfileName = Just GRRBlockedName + | otherwise = Nothing userMemberRole linkRole = \case Just AOAll -> GRObserver Just AONameOnly | noImage -> GRObserver @@ -2475,6 +2488,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv toView $ CRGroupLinkConnecting user gInfo host pure (conn', True) + XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do + (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db vr user conn' glRjct + toView $ CRGroupLinkConnecting user gInfo host + toViewTE $ TEGroupLinkRejected user gInfo rejectionReason + pure (conn', True) -- TODO show/log error, other events in SMP confirmation _ -> pure (conn', False) @@ -2828,7 +2846,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createUnknownMember :: GroupInfo -> MemberId -> CM GroupMember createUnknownMember gInfo memberId = do - let name = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId $ memberId + let name = nameFromMemberId memberId withStore $ \db -> createNewUnknownGroupMember db vr user gInfo memberId name directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index c19e89a776..94e08a0897 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -74,12 +74,13 @@ import Simplex.Messaging.Version hiding (version) -- 10 - business chats (2024-11-29) -- 11 - fix profile update in business chats (2024-12-05) -- 12 - support sending and receiving content reports (2025-01-03) +-- 14 - support sending and receiving group join rejection (2025-02-24) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 12 +currentChatVersion = VersionChat 14 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -130,6 +131,10 @@ businessChatPrefsVersion = VersionChat 11 contentReportsVersion :: VersionChat contentReportsVersion = VersionChat 12 +-- support sending and receiving group join rejection (XGrpLinkReject) +groupJoinRejectVersion :: VersionChat +groupJoinRejectVersion = VersionChat 14 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -326,7 +331,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json - -- XGrpLinkReject :: GroupProfile -> RejectionReason -> ChatMsgEvent 'Json + XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json @@ -816,6 +821,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json XGrpLinkInv_ :: CMEventTag 'Json + XGrpLinkReject_ :: CMEventTag 'Json XGrpLinkMem_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json @@ -867,6 +873,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" XGrpLinkInv_ -> "x.grp.link.inv" + XGrpLinkReject_ -> "x.grp.link.reject" XGrpLinkMem_ -> "x.grp.link.mem" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" @@ -919,6 +926,7 @@ instance StrEncoding ACMEventTag where "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ "x.grp.link.inv" -> XGrpLinkInv_ + "x.grp.link.reject" -> XGrpLinkReject_ "x.grp.link.mem" -> XGrpLinkMem_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ @@ -967,6 +975,7 @@ toCMEventTag msg = case msg of XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ XGrpLinkInv _ -> XGrpLinkInv_ + XGrpLinkReject _ -> XGrpLinkReject_ XGrpLinkMem _ -> XGrpLinkMem_ XGrpMemNew _ -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ @@ -1068,6 +1077,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" + XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" @@ -1130,6 +1140,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] + XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] XGrpLinkMem profile -> o ["profile" .= profile] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index bddbdbcce5..67722ebd0f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -33,6 +33,7 @@ module Simplex.Chat.Store.Groups createGroupInvitation, deleteContactCardKeepConn, createGroupInvitedViaLink, + createGroupRejectedViaLink, setViaGroupLinkHash, setGroupInvitationChatItemId, getGroup, @@ -67,8 +68,8 @@ module Simplex.Chat.Store.Groups getGroupInvitation, createNewContactMember, createNewContactMemberAsync, - createAcceptedMember, - createAcceptedMemberConnection, + createJoiningMember, + createJoiningMemberConnection, createBusinessRequestGroup, getContactViaMember, setNewContactMemberConnRequest, @@ -519,18 +520,33 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -createGroupInvitedViaLink +createGroupInvitedViaLink db vr user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, business} = do + let fromMemberProfile = profileFromName fromMemberName + createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile business GSMemAccepted + +createGroupRejectedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupRejectedViaLink db vr user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do + let fromMemberProfile = profileFromName $ nameFromMemberId memberId + createGroupViaLink' db vr user conn fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected + +createGroupViaLink' :: DB.Connection -> VersionRangeChat -> User -> Connection -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupViaLink' db vr user@User {userId, userContactId} Connection {connId, customUserProfileId} - GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, business} = do + fromMember + fromMemberProfile + invitedMember + groupProfile + business + memStatus = do currentTs <- liftIO getCurrentTime groupId <- insertGroup_ currentTs hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact - void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs vr + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember memStatus IBUnknown customUserProfileId currentTs vr liftIO $ setViaGroupLinkHash db groupId connId (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where @@ -554,7 +570,6 @@ createGroupInvitedViaLink ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db insertHost_ currentTs groupId = do - let fromMemberProfile = profileFromName fromMemberName (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember liftIO $ do @@ -566,7 +581,7 @@ createGroupInvitedViaLink user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, memberId, memberRole, GCHostMember, memStatus, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -783,9 +798,9 @@ getGroupSummary db User {userId} groupId = do JOIN group_members m USING (group_id) WHERE g.user_id = ? AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?) + AND m.member_status NOT IN (?,?,?,?,?) |] - (userId, groupId, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) + (userId, groupId, GSMemRejected, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [(GroupMemberRole, FullGroupPreferences)] @@ -1026,14 +1041,15 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo :. (minV, maxV) ) -createAcceptedMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> ExceptT StoreError IO (GroupMemberId, MemberId) -createAcceptedMember +createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} UserContactRequest {cReqChatVRange, localDisplayName, profileId} - memberRole = + memberRole + memberStatus = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt @@ -1051,13 +1067,13 @@ createAcceptedMember peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) :. (minV, maxV) ) -createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () -createAcceptedMemberConnection +createJoiningMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> VersionChat -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () +createJoiningMemberConnection db user@User {userId} (cmdId, agentConnId) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 0adc91d0d4..72f8e4b8fd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1012,7 +1012,7 @@ Query: JOIN group_members m USING (group_id) WHERE g.user_id = ? AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?) + AND m.member_status NOT IN (?,?,?,?,?) Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) @@ -5025,6 +5025,15 @@ SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=? SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_connection_id (connection_id=?) +Query: DELETE FROM contact_profiles WHERE user_id = ? AND contact_profile_id = ? +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +SEARCH contact_requests USING COVERING INDEX idx_contact_requests_contact_profile_id (contact_profile_id=?) +SEARCH connections USING COVERING INDEX idx_connections_custom_user_profile_id (custom_user_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (member_profile_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) + Query: DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) @@ -5496,6 +5505,10 @@ Query: SELECT count(1) FROM chat_items WHERE chat_item_id > ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid>?) +Query: SELECT count(1) FROM group_members +Plan: +SCAN group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id + Query: SELECT count(1) FROM pending_group_messages Plan: SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id @@ -5532,6 +5545,10 @@ Query: SELECT group_id FROM user_contact_links WHERE user_id = ? AND user_contac Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 +Plan: +SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) + Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ? Plan: SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index e2ee71636b..94b67d8349 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -26,14 +26,17 @@ module Simplex.Chat.Types where +import Control.Applicative ((<|>)) import Crypto.Number.Serialize (os2ip) import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString, pack, unpack) import qualified Data.ByteString.Lazy as LB +import Data.Functor (($>)) import Data.Int (Int64) import Data.Maybe (isJust) import Data.Text (Text) @@ -670,6 +673,41 @@ data GroupLinkInvitation = GroupLinkInvitation } deriving (Eq, Show) +data GroupLinkRejection = GroupLinkRejection + { fromMember :: MemberIdRole, + invitedMember :: MemberIdRole, + groupProfile :: GroupProfile, + rejectionReason :: GroupRejectionReason + } + deriving (Eq, Show) + +data GroupRejectionReason + = GRRLongName + | GRRBlockedName + | GRRUnknown {text :: Text} + deriving (Eq, Show) + +instance FromField GroupRejectionReason where fromField = blobFieldDecoder strDecode + +instance ToField GroupRejectionReason where toField = toField . strEncode + +instance StrEncoding GroupRejectionReason where + strEncode = \case + GRRLongName -> "long_name" + GRRBlockedName -> "blocked_name" + GRRUnknown text -> encodeUtf8 text + strP = + "long_name" $> GRRLongName + <|> "blocked_name" $> GRRBlockedName + <|> GRRUnknown . safeDecodeUtf8 <$> A.takeByteString + +instance FromJSON GroupRejectionReason where + parseJSON = strParseJSON "GroupRejectionReason" + +instance ToJSON GroupRejectionReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole @@ -862,6 +900,9 @@ instance ToJSON MemberId where toJSON = strToJSON toEncoding = strToJEncoding +nameFromMemberId :: MemberId -> ContactName +nameFromMemberId = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId + data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show) @@ -950,7 +991,8 @@ instance TextEncoding GroupMemberCategory where GCPostMember -> "post" data GroupMemberStatus - = GSMemRemoved -- member who was removed from the group + = GSMemRejected -- joining member who was rejected by the host, or host that rejected the join + | GSMemRemoved -- member who was removed from the group | GSMemLeft -- member who left the group | GSMemGroupDeleted -- user member of the deleted group | GSMemUnknown -- unknown member, whose message was forwarded by an admin (likely member wasn't introduced due to not being a current member, but message was included in history) @@ -977,6 +1019,7 @@ instance ToJSON GroupMemberStatus where memberActive :: GroupMember -> Bool memberActive m = case memberStatus m of + GSMemRejected -> False GSMemRemoved -> False GSMemLeft -> False GSMemGroupDeleted -> False @@ -996,6 +1039,7 @@ memberCurrent = memberCurrent' . memberStatus -- update getGroupSummary if this is changed memberCurrent' :: GroupMemberStatus -> Bool memberCurrent' = \case + GSMemRejected -> False GSMemRemoved -> False GSMemLeft -> False GSMemGroupDeleted -> False @@ -1011,6 +1055,7 @@ memberCurrent' = \case memberRemoved :: GroupMember -> Bool memberRemoved m = case memberStatus m of + GSMemRejected -> True GSMemRemoved -> True GSMemLeft -> True GSMemGroupDeleted -> True @@ -1026,6 +1071,7 @@ memberRemoved m = case memberStatus m of instance TextEncoding GroupMemberStatus where textDecode = \case + "rejected" -> Just GSMemRejected "removed" -> Just GSMemRemoved "left" -> Just GSMemLeft "deleted" -> Just GSMemGroupDeleted @@ -1040,6 +1086,7 @@ instance TextEncoding GroupMemberStatus where "creator" -> Just GSMemCreator _ -> Nothing textEncode = \case + GSMemRejected -> "rejected" GSMemRemoved -> "removed" GSMemLeft -> "left" GSMemGroupDeleted -> "deleted" @@ -1793,6 +1840,8 @@ $(JQ.deriveJSON defaultJSON ''GroupInvitation) $(JQ.deriveJSON defaultJSON ''GroupLinkInvitation) +$(JQ.deriveJSON defaultJSON ''GroupLinkRejection) + $(JQ.deriveJSON defaultJSON ''IntroInvitation) $(JQ.deriveJSON defaultJSON ''MemberRestrictions) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 69f9799948..a8c2d215a8 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -444,6 +444,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRAppSettings as -> ["app settings: " <> viewJSON as] CRTimedAction _ _ -> [] CRCustomChatResponse u r -> ttyUser' u $ map plain $ T.lines r + CRTerminalEvent te -> case te of + TERejectingGroupJoinRequestMember _ g m reason -> [ttyFullMember m <> ": rejecting request to join group " <> ttyGroup' g <> ", reason: " <> sShow reason] + TEGroupLinkRejected u g reason -> ttyUser u [ttyGroup' g <> ": join rejected, reason: " <> sShow reason] where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss @@ -1128,7 +1131,7 @@ showRole = plain . strEncode viewGroupMembers :: Group -> [StyledString] viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members where - removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft + removedOrLeft m = let s = memberStatus m in s == GSMemRejected || s == GSMemRemoved || s == GSMemLeft groupMember m = memIncognito m <> ttyFullMember m <> ": " <> plain (intercalate ", " $ [role m] <> category m <> status m <> muted m) role :: GroupMember -> String role GroupMember {memberRole} = B.unpack $ strEncode memberRole @@ -1138,6 +1141,7 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt GCHostMember -> ["host"] _ -> [] status m = case memberStatus m of + GSMemRejected -> ["rejected"] GSMemRemoved -> ["removed"] GSMemLeft -> ["left"] GSMemUnknown -> ["status unknown"] @@ -1178,6 +1182,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g where viewMemberStatus = \case + GSMemRejected -> delete "you are rejected" GSMemRemoved -> delete "you are removed" GSMemLeft -> delete "you left" GSMemGroupDeleted -> delete "group deleted" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bdb3b443b3..6625fb8094 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -98,6 +98,7 @@ chatGroupTests = do it "group link member role" testGroupLinkMemberRole it "host profile received" testGroupLinkHostProfileReceived it "existing contact merged" testGroupLinkExistingContactMerged + it "reject member joining via group link - blocked name" testGroupLinkRejectBlockedName describe "group link connection plan" $ do it "ok to connect; known group" testPlanGroupLinkKnown it "own group link" testPlanGroupLinkOwn @@ -2871,6 +2872,35 @@ testGroupLinkExistingContactMerged = bob #> "#team hi there" alice <# "#team bob> hi there" +testGroupLinkRejectBlockedName :: HasCallStack => TestParams -> IO () +testGroupLinkRejectBlockedName = + testChatCfg2 cfg aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): rejecting request to join group #team, reason: GRRBlockedName" + bob <## "#team: joining the group..." + bob <## "#team: join rejected, reason: GRRBlockedName" + + threadDelay 100000 + + alice `hasContactProfiles` ["alice"] + memCount <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM group_members" :: IO [[Int]] + memCount `shouldBe` [[1]] + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + where + cfg = testCfg {allowedProfileName = Just (const False)} + testPlanGroupLinkKnown :: HasCallStack => TestParams -> IO () testPlanGroupLinkKnown = testChat2 aliceProfile bobProfile $ diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index cb293895a9..83087a89a8 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-12\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-14\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -249,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-14\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-14\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -270,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-14\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"