core: chat relays announce new members to owners (with keys); forward x.grp.mem.restrict; sign and verify x.grp.leave and x.info (member profile update) (#6690)

This commit is contained in:
spaced4ndy
2026-03-19 19:47:02 +00:00
committed by GitHub
parent 74fe5340f7
commit b38fc62281
15 changed files with 579 additions and 293 deletions
+1
View File
@@ -361,6 +361,7 @@ Another member left the group.
- user: [User](./TYPES.md#user)
- groupInfo: [GroupInfo](./TYPES.md#groupinfo)
- member: [GroupMember](./TYPES.md#groupmember)
- msgSigned: bool
---
@@ -251,6 +251,7 @@ export namespace CEvt {
user: T.User
groupInfo: T.GroupInfo
member: T.GroupMember
msgSigned: boolean
}
export interface DeletedMemberUser extends Interface {
+1 -1
View File
@@ -863,7 +863,7 @@ data ChatEvent
| CEvtConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact}
| CEvtDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember, withMessages :: Bool, msgSigned :: Bool}
| CEvtDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, withMessages :: Bool, msgSigned :: Bool}
| CEvtLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CEvtLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, msgSigned :: Bool}
| CEvtUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember}
| CEvtUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember}
| CEvtUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember}
+17 -15
View File
@@ -766,7 +766,7 @@ processChatCommand vr nm = \case
assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier
let msgIds = itemsMsgIds items
events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing $ toMsgScope gInfo <$> chatScopeInfo) msgIds
mapM_ (sendGroupMessages user gInfo Nothing recipients) events
mapM_ (sendGroupMessages user gInfo Nothing False recipients) events
-- TODO delGroupChatItems sends deletion events too. Are they needed?
delGroupChatItems user gInfo chatScopeInfo items False
pure $ CRChatItemsDeleted user deletions True False
@@ -2000,17 +2000,18 @@ processChatCommand vr nm = \case
Just sl -> pure sl
Nothing -> throwChatError $ CEException "failed to retrieve relays: no short link"
(FixedLinkData {linkConnReq = mainCReq@(CRContactUri crData), linkEntityId, rootKey}, ContactLinkData _ UserContactData {owners, relays}) <- getShortLinkConnReq nm user sLnk
-- Set group link info and incognito profile once before connecting to relays
-- Prepare group record once before connecting to relays (updatePreparedRelayedGroup):
-- set group link info and incognito profile, generate and store membership keys
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex}
gInfo' <- withFastStore $ \db -> setPreparedGroupLinkInfo db vr user gInfo mainCReq cReqHash incognitoProfile
forM_ linkEntityId $ \sharedGroupId -> do
gVar <- asks random
(_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar
withFastStore' $ \db -> updateGroupMemberKeys db groupId sharedGroupId rootKey memberPrivKey (groupMemberId' $ membership gInfo')
-- Pre-emptively create owner member with trusted key from link data
forM_ owners $ \OwnerAuth {ownerId, ownerKey} ->
withFastStore $ \db -> createLinkOwnerMember db vr user gInfo' (MemberId ownerId) ownerKey
gVar <- asks random
(_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar
gInfo' <- withFastStore $ \db -> do
gInfo' <- updatePreparedRelayedGroup db vr user gInfo mainCReq cReqHash incognitoProfile linkEntityId rootKey memberPrivKey
-- Pre-emptively create owner members with trusted keys from link data
forM_ owners $ \OwnerAuth {ownerId, ownerKey} ->
void $ createLinkOwnerMember db vr user gInfo' (MemberId ownerId) ownerKey
pure gInfo'
rs <- mapConcurrently (connectToRelay gInfo') relays
let relayFailed = \case (_, _, Left _) -> True; _ -> False
(failed, succeeded) = partition relayFailed rs
@@ -2576,7 +2577,7 @@ processChatCommand vr nm = \case
Just memsToChange' -> do
let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange'
recipients = filter memberCurrent members
(msgs_, _gsr) <- sendGroupMessages user gInfo Nothing recipients events
(msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients events
let signed = any (either (const False) (isJust . signedMsg_)) msgs_
itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_)
cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False
@@ -2705,7 +2706,7 @@ processChatCommand vr nm = \case
Just memsToDelete' -> do
let chatScope = toChatScope <$> chatScopeInfo
events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete'
(msgs_, _gsr) <- sendGroupMessages user gInfo chatScope recipients events
(msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients events
let signed = any (either (const False) (isJust . signedMsg_)) msgs_
itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_)
skipUnwantedItem = \case
@@ -3423,8 +3424,9 @@ processChatCommand vr nm = \case
chatEvent <- case gInfo_ of
Just (Just gInfo) | useRelays' gInfo -> do
let GroupInfo {membership = GroupMember {memberId}} = gInfo
(memberPubKey, _memberPrivKey) <- atomically $ C.generateKeyPair g
-- TODO [member keys] store memberPrivKey in groups.member_priv_key, memberPubKey in group_members.member_pub_key
memberPubKey <- case groupKeys gInfo of
Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey
Nothing -> throwChatError $ CEInternalError "no group keys for channel membership"
pure $ XMember profileToSend memberId (MemberKey memberPubKey)
_ -> pure $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_
dm <- encodeConnInfoPQ pqSup chatV chatEvent
@@ -4095,7 +4097,7 @@ processChatCommand vr nm = \case
(fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients)
timed_ <- sndGroupCITimed live gInfo itemTTL
(chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_
(msgs_, gsr) <- sendGroupMessages user gInfo Nothing recipients chatMsgEvents
(msgs_, gsr) <- sendGroupMessages user gInfo Nothing showGroupAsSender recipients chatMsgEvents
let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_)
cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) showGroupAsSender itemsData timed_ live
when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch"
+18 -18
View File
@@ -923,7 +923,7 @@ acceptContactRequestAsync
liftIO $ setCommandConnId db user cmdId connId
getContact db vr user contactId
acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember
acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> CM GroupMember
acceptGroupJoinRequestAsync
user
uclId
@@ -936,11 +936,12 @@ acceptGroupJoinRequestAsync
welcomeMsgId_
gAccepted
gLinkMemRole
incognitoProfile = do
incognitoProfile
memberKey_ = do
gVar <- asks random
let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted
(groupMemberId, memberId) <- withStore $ \db ->
createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus
createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_
let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo
let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile)
GroupMember {memberRole = userRole, memberId = userMemberId} = membership
@@ -975,7 +976,7 @@ acceptGroupJoinSendRejectAsync
rejectionReason = do
gVar <- asks random
(groupMemberId, memberId) <- withStore $ \db ->
createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected
createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing
let GroupMember {memberRole = userRole, memberId = userMemberId} = membership
msg =
XGrpLinkReject $
@@ -1133,19 +1134,17 @@ memberIntroEvt gInfo reMember =
mRestrictions = memberRestrictions reMember
in XGrpMemIntro mInfo mRestrictions
-- Used in groups with relays to introduce moderators and above to a new member.
-- Member is not introduced to anybody:
-- - in channels member will be prohibited to send, so it doesn't matter;
-- - if member does send, recipients will create unknown member record;
-- - later - to do member profile request protocol.
-- Used in groups with relays to introduce moderators and above to a new member,
-- and to announce the new member to moderators and above.
-- This doesn't create introduction records in db, compared to above methods.
introduceModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM ()
introduceModerators _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active"
introduceModerators vr user gInfo GroupMember {activeConn = Just conn} = do
introduceInChannel :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM ()
introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active"
introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn} = do
modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo
let events = map (memberIntroEvt gInfo) modMs
forM_ (L.nonEmpty events) $ \events' ->
sendGroupMemberMessages user gInfo conn events'
void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing
let introEvts = map (memberIntroEvt gInfo) modMs
forM_ (L.nonEmpty introEvts) $ \introEvts' ->
sendGroupMemberMessages user gInfo conn introEvts'
userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile
userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks
@@ -1996,7 +1995,7 @@ deliverMessagesB msgReqs = do
sendGroupMessage :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> ChatMsgEvent e -> CM SndMessage
sendGroupMessage user gInfo gcScope members chatMsgEvent = do
sendGroupMessages user gInfo gcScope members (chatMsgEvent :| []) >>= \case
sendGroupMessages user gInfo gcScope False members (chatMsgEvent :| []) >>= \case
((Right msg) :| [], _) -> pure msg
_ -> throwChatError $ CEInternalError "sendGroupMessage: expected 1 message"
@@ -2006,8 +2005,8 @@ sendGroupMessage' user gInfo members chatMsgEvent =
((Right msg) :| [], _) -> pure msg
_ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message"
sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
sendGroupMessages user gInfo scope members events = do
sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
sendGroupMessages user gInfo scope asGroup members events = do
-- TODO [knocking] send current profile to pending member after approval?
when shouldSendProfileUpdate $
sendProfileUpdate `catchAllErrors` eToView
@@ -2016,6 +2015,7 @@ sendGroupMessages user gInfo scope members events = do
User {profile = p, userMemberProfileUpdatedAt} = user
GroupInfo {userMemberProfileSentAt} = gInfo
shouldSendProfileUpdate
| asGroup = False
| isJust scope = False -- why not sending profile updates to scopes?
| incognitoMembership gInfo = False
| otherwise =
+81 -64
View File
@@ -853,7 +853,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
when (connChatVersion < batchSend2Version) $ getAutoReplyMsg >>= mapM_ (\mc -> sendGroupAutoReply mc Nothing)
if useRelays' gInfo''
then do
introduceModerators vr user gInfo'' m'
introduceInChannel vr user gInfo'' m'
when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m'
else case mStatus of
GSMemPendingApproval -> pure ()
@@ -941,8 +941,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
pure newDeliveryTasks
processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask)
processEvent gInfo' m' verifiedMsg = do
let chatMsg = verifiedChatMsg verifiedMsg
(m'', conn', msg@RcvMessage {msgId, msgSigned, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg
(m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg
let ctx js = DeliveryTaskContext js False
checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext)
checkSendAsGroup asGroup_ a
@@ -968,7 +967,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs
XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId
XFileAcptInv sharedMsgId fileConnReq_ fName -> Nothing <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName
XInfo p -> fmap ctx <$> xInfoMember gInfo' m'' p brokerTs
XInfo p -> fmap ctx <$> xInfoMember gInfo' m'' p msg brokerTs
XGrpLinkMem p memberKey -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p memberKey
XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs
XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs
@@ -984,7 +983,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
XGrpLeave -> fmap ctx <$> xGrpLeave gInfo' m'' msg brokerTs
XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs
XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs
XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs msgSigned gInfo' m'' ps'
XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg
-- TODO [knocking] why don't we forward these messages?
XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs
XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward gInfo' Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs
@@ -1107,9 +1106,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
let GroupMember {memberId = membershipMemId} = membership
incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo
profileToSend = userProfileInGroup user gInfo incognitoProfile
g <- asks random
(memberPubKey, _memberPrivKey) <- atomically $ C.generateKeyPair g
-- TODO [member keys] store memberPrivKey in groups.member_priv_key, memberPubKey in group_members.member_pub_key
memberPubKey <- case groupKeys gInfo of
Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey
Nothing -> throwChatError $ CEInternalError "no group keys for channel membership"
dm <- encodeConnInfo $ XMember profileToSend membershipMemId (MemberKey memberPubKey)
subMode <- chatReadVar subscriptionMode
void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode
@@ -1418,7 +1417,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
messageError "processContactConnMessage: chat version range incompatible for accepting group join request"
| otherwise -> do
let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo
mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode
mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing
(gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing
toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem'
@@ -1435,18 +1434,22 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
-- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays
-- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember)
memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM ()
memberJoinRequestViaRelay invId chatVRange p joiningMemberId _joiningMemberKey = do -- TODO [member keys] store memberKey in group_members.member_pub_key
memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey = do
(_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId
case gLinkInfo_ of
Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do
gInfo <- withStore $ \db -> getGroupInfo db vr user groupId
mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing
mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing (Just joiningMemberKey)
(gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing
toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem'
Nothing ->
messageError "memberJoinRequestViaRelay: no group link info for relay link"
muteEventInChannel :: GroupInfo -> GroupMember -> Bool
muteEventInChannel gInfo@GroupInfo {membership} m =
useRelays' gInfo && memberRole' membership < GRModerator && not (isRelay membership) && memberRole' m < GRModerator
memberCanSend :: Maybe GroupMember -> Maybe MsgScope -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext)
memberCanSend Nothing _ a = a -- channel message - was previously checked and allowed by relay
memberCanSend (Just m@GroupMember {memberRole}) msgScope a = case msgScope of
@@ -2394,9 +2397,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
Profile {displayName = n, fullName = fn, shortDescr = sd, image = i, contactLink = cl} = p
Profile {displayName = n', fullName = fn', shortDescr = sd', image = i', contactLink = cl'} = p'
xInfoMember :: GroupInfo -> GroupMember -> Profile -> UTCTime -> CM (Maybe DeliveryJobScope)
xInfoMember gInfo m p' brokerTs = do
void $ processMemberProfileUpdate gInfo m p' True (Just brokerTs)
xInfoMember :: GroupInfo -> GroupMember -> Profile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
xInfoMember gInfo m p' msg brokerTs = do
void $ processMemberProfileUpdate gInfo m p' (Just (msg, brokerTs))
pure $ memberEventDeliveryScope m
xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> Maybe MemberKey -> CM ()
@@ -2404,7 +2407,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId
if (viaGroupLink || isJust businessChat) && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived
then do
m' <- processMemberProfileUpdate gInfo m p' False Nothing
m' <- processMemberProfileUpdate gInfo m p' Nothing
withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True memberKey_
let connectedIncognito = memberIncognito membership
probeMatchingMemberContact m' connectedIncognito
@@ -2468,24 +2471,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
where
expectHistory = groupFeatureAllowed SGFHistory gInfo && m `supportsVersion` groupHistoryIncludeWelcomeVersion
processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> Maybe UTCTime -> CM GroupMember
processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems itemTs_
processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Maybe (RcvMessage, UTCTime) -> CM GroupMember
processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' msgTs_
| redactedMemberProfile allowSimplexLinks (fromLocalProfile p) /= redactedMemberProfile allowSimplexLinks p' = do
updateBusinessChatProfile gInfo
case memberContactId of
Nothing -> do
m' <- withStore $ \db -> updateMemberProfile db user m p'
createProfileUpdatedItem m'
toView $ CEvtGroupMemberUpdated user gInfo m m'
unless (muteEventInChannel gInfo m') $ do
forM_ msgTs_ $ createProfileUpdatedItem m'
toView $ CEvtGroupMemberUpdated user gInfo m m'
pure m'
Just mContactId -> do
mCt <- withStore $ \db -> getContact db vr user mContactId
if canUpdateProfile mCt
then do
(m', ct') <- withStore $ \db -> updateContactMemberProfile db user m mCt p'
createProfileUpdatedItem m'
toView $ CEvtGroupMemberUpdated user gInfo m m'
toView $ CEvtContactUpdated user mCt ct'
unless (muteEventInChannel gInfo m') $ do
forM_ msgTs_ $ createProfileUpdatedItem m'
toView $ CEvtGroupMemberUpdated user gInfo m m'
toView $ CEvtContactUpdated user mCt ct'
pure m'
else pure m
where
@@ -2506,11 +2511,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
isMainBusinessMember BusinessChatInfo {chatType, businessId, customerId} GroupMember {memberId} = case chatType of
BCBusiness -> businessId == memberId
BCCustomer -> customerId == memberId
createProfileUpdatedItem m' =
when createItems $ do
(gInfo', m'', scopeInfo) <- mkGroupChatScope gInfo m'
let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p'
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m'') ciContent itemTs_
createProfileUpdatedItem m' (msg, brokerTs) = do
(gInfo', m'', scopeInfo) <- mkGroupChatScope gInfo m'
let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p'
cd = CDGroupRcv gInfo' scopeInfo m''
(ci, cInfo) <- saveRcvChatItemNoParse user cd msg brokerTs ciContent
groupMsgToView cInfo ci
xInfoProbe :: ContactOrMember -> Probe -> CM ()
xInfoProbe cgm2 probe = do
@@ -2746,7 +2752,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ _) msgScope_ msg brokerTs = do
checkHostRole m memRole
unless (useRelays' gInfo && isRelay m) $ checkHostRole m memRole
if sameMemberId memId (membership gInfo)
then pure Nothing
else do
@@ -2762,7 +2768,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember
memberAnnouncedToView updatedMember gInfo'
pure $ deliveryJobScope updatedMember
Right _ -> messageError "x.grp.mem.new error: member already exists" $> Nothing
Right _
| useRelays' gInfo -> logInfo "x.grp.mem.new: member already created via another relay" $> Nothing
| otherwise -> messageError "x.grp.mem.new error: member already exists" $> Nothing
Left _ -> do
(newMember, gInfo') <- withStore $ \db -> do
newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus
@@ -2804,7 +2812,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case
Right existingMember
| useRelays' gInfo ->
void $ withStore $ \db -> updateIntroducedMember db vr user existingMember memInfo
void $ withStore $ \db -> updatePreparedChannelMember db vr user existingMember memInfo
| otherwise ->
messageError "x.grp.mem.intro ignored: member already exists"
Left _
@@ -2914,22 +2922,24 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
| membershipMemId == memId = pure Nothing -- ignore - XGrpMemRestrict can be sent to restricted member for efficiency
| otherwise = do
unknownRole <- unknownMemberRole gInfo
(bm, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memId "" unknownRole
let GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} = bm
if
| blockedByAdmin == mrsBlocked restriction -> pure Nothing
| senderRole < GRModerator || senderRole < memberRole ->
messageError "x.grp.mem.restrict with insufficient member permissions" $> Nothing
| otherwise -> do
bm' <- setMemberBlocked bm
toggleNtf bm' (not blocked)
let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked
(gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m
(ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent
when unknown $ toView $ CEvtUnknownMemberBlocked user gInfo m bm'
groupMsgToView cInfo ci
toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm', blocked, msgSigned}
pure $ memberEventDeliveryScope bm
withStore (\db -> getCreateUnknownGMByMemberId db vr user gInfo memId "" unknownRole True) >>= \case
Nothing -> messageError "x.grp.mem.restrict: no member" $> Nothing -- shouldn't happen
Just (bm, unknown) -> do
let GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} = bm
if
| blockedByAdmin == mrsBlocked restriction -> pure Nothing
| senderRole < GRModerator || senderRole < memberRole ->
messageError "x.grp.mem.restrict with insufficient member permissions" $> Nothing
| otherwise -> do
bm' <- setMemberBlocked bm
toggleNtf bm' (not blocked)
let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked
(gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m
(ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent
when unknown $ toView $ CEvtUnknownMemberBlocked user gInfo m bm'
groupMsgToView cInfo ci
toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm', blocked, msgSigned}
pure $ memberEventDeliveryScope bm
where
setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm
blocked = mrsBlocked restriction
@@ -3016,14 +3026,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
| otherwise = pure GRAuthor
xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
xGrpLeave gInfo m msg brokerTs = do
xGrpLeave gInfo m msg@RcvMessage {msgSigned} brokerTs = do
deleteMemberConnection m
-- member record is not deleted to allow creation of "member left" chat item
gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft
(gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m
(ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft)
groupMsgToView cInfo ci
toView $ CEvtLeftMember user gInfo'' m' {memberStatus = GSMemLeft}
unless (muteEventInChannel gInfo' m) $ do
(gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m
(ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft)
groupMsgToView cInfo ci
toView $ CEvtLeftMember user gInfo'' m' {memberStatus = GSMemLeft} msgSigned
pure $ memberEventDeliveryScope m
xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM ()
@@ -3056,8 +3067,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p'
pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}}
xGrpPrefs :: Bool -> GroupInfo -> GroupMember -> GroupPreferences -> CM (Maybe DeliveryJobScope)
xGrpPrefs msgSigned g m@GroupMember {memberRole} ps'
xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> RcvMessage -> CM (Maybe DeliveryJobScope)
xGrpPrefs g m@GroupMember {memberRole} ps' RcvMessage {msgSigned}
| memberRole < GROwner = messageError "x.grp.prefs with insufficient member permissions" $> Nothing
| otherwise = updateGroupPrefs_ msgSigned g m ps' $> Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}}
@@ -3165,17 +3176,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
case fwdSender of
FwdMember memberId memberName -> do
unknownRole <- unknownMemberRole gInfo
(author, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownRole
when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author
void $ withVerifiedMsg gInfo scopeInfo author parsedMsg msgTs $
(`processForwardedMsg` Just author)
let allowCreate = toCMEventTag chatMsgEvent /= XGrpLeave_
withStore (\db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownRole allowCreate) >>= \case
Just (author, unknown) -> do
when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author
void $ withVerifiedMsg gInfo scopeInfo author parsedMsg msgTs $
(`processForwardedMsg` Just author)
Nothing -> pure ()
FwdChannel -> processForwardedMsg (VMUnsigned chatMsg) Nothing
where
-- ! see isForwardedGroupMsg: forwarded group events should include msgId to be deduplicated
processForwardedMsg :: VerifiedMsg 'Json -> Maybe GroupMember -> CM ()
processForwardedMsg verifiedMsg author_ = do
rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author_ verifiedMsg brokerTs
forM_ rcvMsg_ $ \rcvMsg@RcvMessage {msgSigned, chatMsgEvent = ACME _ event} -> case event of
forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of
XMsgNew mc ->
void $ memberCanSend author_ scope $ newGroupContentMessage gInfo author_ mc rcvMsg msgTs True
where
@@ -3187,14 +3201,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ rcvMsg msgTs
XMsgReact sharedMsgId memId scope_ reaction add -> withAuthor XMsgReact_ $ \author -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs
XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId
XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p msgTs
XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs
XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs
XGrpMemRole memId memRole -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs
XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs
XGrpMemDel memId withMessages -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages verifiedMsg rcvMsg msgTs True
XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave gInfo author rcvMsg msgTs
XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs
XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs
XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs msgSigned gInfo author ps'
XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg
_ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event)
where
withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM ()
@@ -3203,7 +3218,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author"
withVerifiedMsg :: MsgEncodingI e => GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a)
withVerifiedMsg gInfo scopeInfo member (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action
withVerifiedMsg gInfo@GroupInfo {membership} scopeInfo member (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action
| verified = Just <$> action verifiedMsg
| otherwise = do
createInternalChatItem user (CDGroupRcv gInfo scopeInfo member) (CIRcvGroupEvent RGEMsgBadSignature) (Just ts)
@@ -3219,10 +3234,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
CBGroup | Just GroupKeys {groupRootKey} <- groupKeys gInfo ->
let prefix = smpEncode chatBinding <> smpEncode (groupRootPubKey groupRootKey, memberId)
in all (\(MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures
_ -> False
| otherwise -> signatureOptional
_ -> signatureOptional
| otherwise -> signatureOptional || unverifiedAllowed membership member tag
Nothing -> signatureOptional
signatureOptional = not (useRelays' gInfo && requiresSignature (toCMEventTag chatMsgEvent))
where
tag = toCMEventTag chatMsgEvent
signatureOptional = not (useRelays' gInfo) || not (requiresSignature tag)
directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM ()
directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do
+22 -2
View File
@@ -38,7 +38,7 @@ import Data.Either (fromRight)
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust)
import Data.Maybe (fromMaybe, isJust, isNothing)
import Data.String
import Data.Text (Text)
import qualified Data.Text as T
@@ -1191,7 +1191,7 @@ hasDeliveryReceipt = \case
XCallInv_ -> True
_ -> False
-- | Admin events that must have a valid signature in relay groups.
-- | Events that must have a valid signature in relay groups.
requiresSignature :: CMEventTag e -> Bool
requiresSignature = \case
XGrpDel_ -> True
@@ -1200,8 +1200,28 @@ requiresSignature = \case
XGrpMemDel_ -> True
XGrpMemRole_ -> True
XGrpMemRestrict_ -> True
XGrpLeave_ -> True
XInfo_ -> True
_ -> False
-- TODO [relays] relay: vectors tracking which members received which other member profiles/keys.
-- TODO - don't forward XGrpLeave/XInfo to members who haven't seen sender's profile/key.
-- TODO - unverifiedAllowed is a temporary workaround postponing targeted event forwarding.
-- Allow signed but unverified XGrpLeave/XInfo between subscribers when sender's key is unknown.
-- Owner keys are always known, so subscribers are required to verify from owners.
-- Likewise, subscriber keys are always known to owners, so owners are required to verify from subscribers.
unverifiedAllowed :: GroupMember -> GroupMember -> CMEventTag e -> Bool
unverifiedAllowed membership member = \case
XGrpLeave_ -> membersNoKey
XInfo_ -> membersNoKey
_ -> False
where
membersNoKey =
memberRole' membership < GRModerator
&& memberRole' member < GRModerator
&& isNothing (memberPubKey member)
appBinaryToCM :: AppMessageBinary -> Either String (ChatMessage 'Binary)
appBinaryToCM AppMessageBinary {msgId, tag, body} = do
eventTag <- strDecode $ B.singleton tag
+1 -1
View File
@@ -179,7 +179,7 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta
)
connId <- insertedRowId db
case preparedEntity_ of
-- For relay groups, setPreparedGroupLinkInfo is called before the relay loop
-- For relay groups, setPreparedGroupLinkInfo_ is called via updatePreparedRelayedGroup before the relay loop
Just (PCEGroup gInfo _) | not (useRelays' gInfo) ->
setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId currentTs
_ -> pure ()
+44 -20
View File
@@ -100,6 +100,7 @@ module Simplex.Chat.Store.Groups
getMemberInvitation,
createMemberConnection,
createMemberConnectionAsync,
updatePreparedRelayedGroup,
updateGroupMemberKeys,
updateRelayGroupKeys,
updateGroupMemberStatus,
@@ -151,7 +152,7 @@ module Simplex.Chat.Store.Groups
setXGrpLinkMemReceived,
createNewUnknownGroupMember,
createLinkOwnerMember,
updateIntroducedMember,
updatePreparedChannelMember,
updateUnknownMemberAnnounced,
updateUserMemberProfileSentAt,
setGroupCustomData,
@@ -1090,14 +1091,16 @@ getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId =
(groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?")
(groupId, memberId)
getCreateUnknownGMByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ContactName -> GroupMemberRole -> ExceptT StoreError IO (GroupMember, Bool)
getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownMemberRole = do
getCreateUnknownGMByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ContactName -> GroupMemberRole -> Bool -> ExceptT StoreError IO (Maybe (GroupMember, Bool))
getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownMemberRole allowCreate = do
liftIO (runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case
Right m -> pure (m, False)
Left (SEGroupMemberNotFoundByMemberId _) -> do
let name = if T.null memberName then nameFromMemberId memberId else memberName
m <- createNewUnknownGroupMember db vr user gInfo memberId name unknownMemberRole
pure (m, True)
Right m -> pure $ Just (m, False)
Left (SEGroupMemberNotFoundByMemberId _)
| allowCreate -> do
let name = if T.null memberName then nameFromMemberId memberId else memberName
m <- createNewUnknownGroupMember db vr user gInfo memberId name unknownMemberRole
pure $ Just (m, True)
| otherwise -> pure Nothing
Left e -> throwError e
getScopeMemberIdViaMemberId :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberId -> ExceptT StoreError IO GroupMemberId
@@ -1522,7 +1525,7 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo
:. (minV, maxV)
)
createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId)
createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId)
createJoiningMember
db
gVar
@@ -1534,7 +1537,8 @@ createJoiningMember
cReqMemberId_
welcomeMsgId_
memberRole
memberStatus = do
memberStatus
memberKey_ = do
currentTs <- liftIO getCurrentTime
ExceptT . withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
liftIO $
@@ -1561,6 +1565,7 @@ createJoiningMember
checkMemberNotExists memberId = do
exists <- liftIO $ fromOnly . head <$> DB.query db "SELECT EXISTS (SELECT 1 FROM group_members WHERE group_id = ? AND member_id = ?)" (groupId, memberId)
when exists $ throwError SEDuplicateMemberId
memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey_
insertMember_ ldn profileId memberId currentTs = do
indexInGroup <- getUpdateNextIndexInGroup_ db groupId
liftIO $
@@ -1569,12 +1574,12 @@ createJoiningMember
[sql|
INSERT INTO group_members
( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at,
user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership)
:. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs)
:. (userId, ldn, Nothing :: (Maybe Int64), profileId, memberPubKey_, cReqXContactId_, welcomeMsgId_, currentTs, currentTs)
:. (minV, maxV)
)
@@ -1698,13 +1703,27 @@ createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentCon
Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 currentTs subMode
setCommandConnId db user cmdId connId
updateGroupMemberKeys :: DB.Connection -> GroupId -> ByteString -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> GroupMemberId -> IO ()
updateGroupMemberKeys db groupId sharedGroupId rootPubKey memberPrivKey membershipGMId = do
-- Set group link info, incognito profile, membership keys before connecting to relays.
-- This is called once before connecting to relays, unlike createConnReqConnection -> setPreparedGroupLinkInfo_,
-- which is used in single-connection flows.
updatePreparedRelayedGroup ::
DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile ->
Maybe ByteString -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 ->
ExceptT StoreError IO GroupInfo
updatePreparedRelayedGroup db vr user@User {userId} gInfo cReq cReqHash incognitoProfile linkEntityId rootPubKey memberPrivKey = do
currentTs <- liftIO getCurrentTime
customUserProfileId <- liftIO $ mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile
liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId currentTs
liftIO $ updateGroupMemberKeys db (groupId' gInfo) linkEntityId rootPubKey memberPrivKey (groupMemberId' $ membership gInfo)
getGroupInfo db vr user (groupId' gInfo)
updateGroupMemberKeys :: DB.Connection -> GroupId -> Maybe ByteString -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> GroupMemberId -> IO ()
updateGroupMemberKeys db groupId linkEntityId rootPubKey memberPrivKey membershipGMId = do
currentTs <- getCurrentTime
DB.execute
db
"UPDATE groups SET shared_group_id = ?, root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ?"
(Binary sharedGroupId, rootPubKey, memberPrivKey, currentTs, groupId)
(Binary <$> linkEntityId, rootPubKey, memberPrivKey, currentTs, groupId)
DB.execute
db
"UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?"
@@ -2851,8 +2870,11 @@ createLinkOwnerMember db vr user@User {userId, userContactId} GroupInfo {groupId
where
VersionRange minV maxV = vr
updateIntroducedMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember
updateIntroducedMember db vr user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do
-- member_pub_key is not updated here — introduced members are owners
-- whose keys are loaded from link data (trusted out-of-band).
-- Updating from an in-band message would allow a compromised relay to substitute keys.
updatePreparedChannelMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember
updatePreparedChannelMember db vr user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do
_ <- updateMemberProfile db user member profile
currentTs <- liftIO getCurrentTime
liftIO $
@@ -2873,7 +2895,7 @@ updateIntroducedMember db vr user@User {userId} member@GroupMember {groupMemberI
VersionRange minV maxV = maybe memberChatVRange fromChatVRange v
updateUnknownMemberAnnounced :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember
updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} status = do
updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do
_ <- updateMemberProfile db user unknownMember profile
currentTs <- liftIO getCurrentTime
liftIO $
@@ -2887,15 +2909,17 @@ updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMemb
invited_by_group_member_id = ?,
peer_chat_min_version = ?,
peer_chat_max_version = ?,
member_pub_key = ?,
updated_at = ?
WHERE user_id = ? AND group_member_id = ?
|]
( (memberRole, GCPostMember, status, groupMemberId' invitingMember)
:. (minV, maxV, currentTs, userId, groupMemberId)
:. (minV, maxV, memberPubKey_, currentTs, userId, groupMemberId)
)
getGroupMemberById db vr user groupMemberId
where
VersionRange minV maxV = maybe memberChatVRange fromChatVRange v
memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey
updateUserMemberProfileSentAt :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO ()
updateUserMemberProfileSentAt db User {userId} GroupInfo {groupId} sentTs =
@@ -273,16 +273,6 @@ Query:
Plan:
SEARCH connections USING PRIMARY KEY (conn_id=?)
Query:
SELECT user_id FROM users u
WHERE u.deleted = ?
AND NOT EXISTS (SELECT c.conn_id FROM connections c WHERE c.user_id = u.user_id)
Plan:
SCAN u
CORRELATED SCALAR SUBQUERY 1
SEARCH c USING COVERING INDEX idx_connections_user (user_id=?)
Query:
SELECT user_id FROM users u
WHERE u.user_id = ?
@@ -535,54 +525,6 @@ Query:
Plan:
SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?)
Query:
DELETE FROM encrypted_rcv_message_hashes
WHERE encrypted_rcv_message_hash_id IN (
SELECT encrypted_rcv_message_hash_id
FROM encrypted_rcv_message_hashes
WHERE created_at < ?
ORDER BY created_at ASC
LIMIT ?
)
Plan:
SEARCH encrypted_rcv_message_hashes USING INTEGER PRIMARY KEY (rowid=?)
LIST SUBQUERY 1
SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_created_at (created_at<?)
Query:
DELETE FROM messages
WHERE (conn_id, internal_id) IN (
SELECT conn_id, internal_id
FROM messages
WHERE internal_ts < ? AND internal_snd_id IS NOT NULL
ORDER BY internal_ts ASC
LIMIT ?
)
Plan:
SEARCH messages USING COVERING INDEX idx_messages_conn_id (conn_id=? AND internal_id=?)
LIST SUBQUERY 1
SEARCH messages USING INDEX idx_messages_internal_ts (internal_ts<?)
SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=? AND internal_id=?)
SEARCH snd_messages USING COVERING INDEX idx_snd_messages_conn_id_internal_id (conn_id=? AND internal_id=?)
SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?)
Query:
DELETE FROM processed_ratchet_key_hashes
WHERE processed_ratchet_key_hash_id IN (
SELECT processed_ratchet_key_hash_id
FROM processed_ratchet_key_hashes
WHERE created_at < ?
ORDER BY created_at ASC
LIMIT ?
)
Plan:
SEARCH processed_ratchet_key_hashes USING INTEGER PRIMARY KEY (rowid=?)
LIST SUBQUERY 1
SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_created_at (created_at<?)
Query:
INSERT INTO connections
(user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, pq_support, duplex_handshake) VALUES (?,?,?,?,?,?,?)
@@ -707,30 +649,6 @@ Query:
Plan:
Query:
SELECT rcv_file_id, rcv_file_entity_id, prefix_path
FROM rcv_files
WHERE created_at < ?
Plan:
SCAN rcv_files
Query:
SELECT rcv_file_id, rcv_file_entity_id, prefix_path
FROM rcv_files
WHERE deleted = 1
Plan:
SCAN rcv_files
Query:
SELECT rcv_file_id, rcv_file_entity_id, tmp_path
FROM rcv_files
WHERE status IN (?,?) AND tmp_path IS NOT NULL
Plan:
SEARCH rcv_files USING INDEX idx_rcv_files_status_created_at (status=?)
Query:
SELECT rcv_replica_id, rcv_replica_key
FROM snd_file_chunk_replica_recipients
@@ -739,30 +657,6 @@ Query:
Plan:
SEARCH snd_file_chunk_replica_recipients USING INDEX idx_snd_file_chunk_replica_recipients_snd_file_chunk_replica_id (snd_file_chunk_replica_id=?)
Query:
SELECT snd_file_id, snd_file_entity_id, prefix_path
FROM snd_files
WHERE created_at < ?
Plan:
SCAN snd_files
Query:
SELECT snd_file_id, snd_file_entity_id, prefix_path
FROM snd_files
WHERE deleted = 1
Plan:
SCAN snd_files
Query:
SELECT snd_file_id, snd_file_entity_id, prefix_path
FROM snd_files
WHERE status IN (?,?) AND prefix_path IS NOT NULL
Plan:
SEARCH snd_files USING INDEX idx_snd_files_status_created_at (status=?)
Query:
UPDATE conn_confirmations
SET accepted = 1,
@@ -1054,10 +948,6 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id (conn_id=?)
SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?)
SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?)
Query: DELETE FROM deleted_snd_chunk_replicas WHERE created_at < ?
Plan:
SEARCH deleted_snd_chunk_replicas USING COVERING INDEX idx_deleted_snd_chunk_replicas_pending (created_at<?)
Query: DELETE FROM deleted_snd_chunk_replicas WHERE deleted_snd_chunk_replica_id = ?
Plan:
SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?)
@@ -1077,10 +967,6 @@ Query: DELETE FROM ntf_subscriptions WHERE conn_id = ?
Plan:
SEARCH ntf_subscriptions USING PRIMARY KEY (conn_id=?)
Query: DELETE FROM ntf_tokens_to_delete WHERE created_at < ?
Plan:
SCAN ntf_tokens_to_delete
Query: DELETE FROM ratchets WHERE conn_id = ?
Plan:
SEARCH ratchets USING PRIMARY KEY (conn_id=?)
@@ -1199,14 +1085,6 @@ Query: SELECT conn_id FROM connections WHERE deleted = 0
Plan:
SCAN connections
Query: SELECT conn_id FROM connections WHERE deleted = ?
Plan:
SCAN connections
Query: SELECT conn_id FROM connections WHERE deleted_at_wait_delivery IS NOT NULL
Plan:
SCAN connections
Query: SELECT conn_id FROM connections WHERE user_id = ?
Plan:
SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?)
@@ -349,9 +349,9 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta
Query:
INSERT INTO group_members
( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id,
user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at,
user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at,
peer_chat_min_version, peer_chat_max_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
@@ -6659,6 +6659,10 @@ Query: SELECT member_relations_vector FROM group_members WHERE group_member_id =
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query: SELECT member_status FROM group_members WHERE local_display_name = ?
Plan:
SCAN group_members
Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_members WHERE user_id = ? AND group_id = ? AND group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
-10
View File
@@ -861,16 +861,6 @@ getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do
(groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?")
(groupId, userId, userContactId)
-- Set group link info and optionally incognito profile before connecting to relays.
-- This is called once before connecting to relays, unlike createConnReqConnection -> setPreparedGroupLinkInfo_,
-- which is used in single-connection flows.
setPreparedGroupLinkInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> ExceptT StoreError IO GroupInfo
setPreparedGroupLinkInfo db vr user@User {userId} gInfo@GroupInfo {groupId} cReq cReqHash incognitoProfile = do
currentTs <- liftIO getCurrentTime
customUserProfileId <- liftIO $ mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile
liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId currentTs
getGroupInfo db vr user groupId
setPreparedGroupLinkInfo_ :: DB.Connection -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Int64 -> UTCTime -> IO ()
setPreparedGroupLinkInfo_ db GroupInfo {groupId, membership} cReq cReqHash customUserProfileId currentTs = do
DB.execute
+3 -2
View File
@@ -476,7 +476,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView}
CEvtMemberBlockedForAll u g by m blocked signed -> ttyUser u $ viewMemberBlockedForAll g by m blocked signed
CEvtDeletedMemberUser u g by wm signed -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group" <> withMessages wm <> signedStr signed] <> groupPreserved g
CEvtDeletedMember u g by m wm signed -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group" <> withMessages wm <> signedStr signed]
CEvtLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"]
CEvtLeftMember u g m signed -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group" <> signedStr signed]
CEvtGroupDeleted u g m signed -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group" <> signedStr signed, "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"]
CEvtGroupUpdated u g g' m signed -> ttyUser u $ viewGroupUpdated g g' m signed
CEvtAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."]
@@ -1276,7 +1276,8 @@ viewJoinedGroupMemberConnecting g@GroupInfo {groupId} host m@GroupMember {groupM
[ (ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting and pending review...), ")
<> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " <role>") <> " to accept member")
]
_ -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"]
_ | useRelays' g -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group"]
| otherwise -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"]
viewConnectedToGroupMember :: GroupInfo -> GroupMember -> [StyledString]
viewConnectedToGroupMember g@GroupInfo {groupId} m@GroupMember {groupMemberId, memberStatus} = case memberStatus of
+374 -36
View File
@@ -18,7 +18,7 @@ import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Monad (forM_, void, when)
import Data.Bifunctor (second)
import Data.Maybe (fromMaybe)
import Data.Maybe (fromMaybe, maybeToList)
import qualified Data.ByteString.Char8 as B
import Data.Int (Int64)
import Data.List (intercalate, isInfixOf)
@@ -253,9 +253,14 @@ chatGroupTests = do
it "should update channel profile (signed)" testChannelUpdateProfileSigned
it "should update channel preferences (signed)" testChannelUpdatePrefsSigned
it "should change member role (signed)" testChannelChangeRoleSigned
it "should block member for all (signed)" testChannelBlockMemberSigned
it "should remove member (signed)" testChannelRemoveMemberSigned
it "should delete channel (signed)" testChannelDeleteGroupSigned
it "should delete channel and clean up relay connections" testChannelDeleteGroupCleanup
it "owner should leave channel (signed)" testChannelOwnerLeave
it "subscriber should leave channel (signed)" testChannelSubscriberLeave
it "owner should update profile in channel (signed)" testChannelOwnerProfileUpdate
it "subscriber should update profile in channel (signed)" testChannelSubscriberProfileUpdate
describe "channel message operations" $ do
it "should update channel message" testChannelMessageUpdate
it "should delete channel message" testChannelMessageDelete
@@ -8406,7 +8411,7 @@ testChannels1RelayDeliver ps =
cath <## "added 👍"
bob <# "#team cath> > hi"
bob <## " + 👍"
alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
-- alice knows cath via XGrpMemNew announcement from relay
alice <# "#team cath> > hi"
alice <## " + 👍"
dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
@@ -8420,7 +8425,7 @@ createChannel1Relay :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC
createChannel1Relay gName owner relay cath dan eve = do
(shortLink, fullLink) <- prepareChannel1Relay gName owner relay
forM_ [cath, dan, eve] $ \member ->
memberJoinChannel gName [relay] shortLink fullLink member
memberJoinChannel gName [relay] [owner] shortLink fullLink member
prepareChannel1Relay :: String -> TestCC -> TestCC -> IO (String, String)
prepareChannel1Relay gName owner relay = do
@@ -8453,7 +8458,7 @@ createChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC
createChannel2Relays gName owner relay1 relay2 dan eve frank = do
(shortLink, fullLink) <- prepareChannel2Relays gName owner relay1 relay2
forM_ [dan, eve, frank] $ \member ->
memberJoinChannel gName [relay1, relay2] shortLink fullLink member
memberJoinChannel gName [relay1, relay2] [owner] shortLink fullLink member
prepareChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> IO (String, String)
prepareChannel2Relays gName owner relay1 relay2 = do
@@ -8494,8 +8499,8 @@ prepareChannel2Relays gName owner relay1 relay2 = do
owner ##> ("/show link #" <> gName)
getGroupLinks owner gName GRMember False
memberJoinChannel :: String -> [TestCC] -> String -> String -> TestCC -> IO ()
memberJoinChannel gName relays shortLink fullLink member = do
memberJoinChannel :: String -> [TestCC] -> [TestCC] -> String -> String -> TestCC -> IO ()
memberJoinChannel gName relays owners shortLink fullLink member = do
mName <- userName member
mFullName <- showName member
relayNames <- mapM userName relays
@@ -8523,9 +8528,12 @@ memberJoinChannel gName relays shortLink fullLink member = do
relay <## ("#" <> gName <> ": " <> mName <> " joined the group")
| relay <- relays
]
<> [ owner <### [EndsWith ("added " <> mFullName <> " to the group")]
| owner <- owners
]
memberJoinChannelIncognito :: String -> [TestCC] -> String -> String -> TestCC -> IO String
memberJoinChannelIncognito gName relays shortLink fullLink member = do
memberJoinChannelIncognito :: String -> [TestCC] -> [TestCC] -> String -> String -> TestCC -> IO String
memberJoinChannelIncognito gName relays owners shortLink fullLink member = do
relayNames <- mapM userName relays
member ##> ("/_connect plan 1 " <> shortLink)
@@ -8552,6 +8560,9 @@ memberJoinChannelIncognito gName relays shortLink fullLink member = do
relay <## ("#" <> gName <> ": " <> memIncognito <> " joined the group")
| relay <- relays
]
<> [ owner <### [EndsWith ("added " <> memIncognito <> " to the group")]
| owner <- owners
]
pure memIncognito
testChannels1RelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO ()
@@ -8571,7 +8582,6 @@ testChannels1RelayDeliverLoop deliveryBucketSize ps =
cath <## "added 👍"
bob <# "#team cath> > hi"
bob <## " + 👍"
alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
alice <# "#team cath> > hi"
alice <## " + 👍"
dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
@@ -8611,9 +8621,7 @@ testChannelsSenderDeduplicateOwn ps = do
WithTime "#team dan> 6"
]
alice
<### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath",
"#team: bob forwarded a message from an unknown member, creating unknown member record dan",
WithTime "#team cath> 4 [>>]",
<### [ WithTime "#team cath> 4 [>>]",
WithTime "#team cath> 5 [>>]",
WithTime "#team dan> 6 [>>]"
]
@@ -8665,7 +8673,6 @@ testChannels2RelaysDeliver ps =
bob <## " + 👍"
cath <# "#team dan> > hi"
cath <## " + 👍"
alice .<## " forwarded a message from an unknown member, creating unknown member record dan"
alice <# "#team dan> > hi"
alice <## " + 👍"
eve .<## " forwarded a message from an unknown member, creating unknown member record dan"
@@ -8689,9 +8696,9 @@ testChannels2RelaysIncognito ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
withNewTestChat ps "frank" frankProfile $ \frank -> do
(shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath
danIncognito <- memberJoinChannelIncognito "team" [bob, cath] shortLink fullLink dan
danIncognito <- memberJoinChannelIncognito "team" [bob, cath] [alice] shortLink fullLink dan
forM_ [eve, frank] $ \member ->
memberJoinChannel "team" [bob, cath] shortLink fullLink member
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink member
alice #> "#team hi"
[bob, cath] *<# "#team> hi"
@@ -8704,7 +8711,6 @@ testChannels2RelaysIncognito ps =
bob <## " + 👍"
cath <# ("#team " <> danIncognito <> "> > hi")
cath <## " + 👍"
alice .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito)
alice <# ("#team " <> danIncognito <> "> > hi")
alice <## " + 👍"
eve .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito)
@@ -8791,13 +8797,11 @@ testChannelChangeRoleSigned ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
-- discover cath so alice can change her role
-- other members discover cath
cath #> "#team hello from cath"
bob <# "#team cath> hello from cath"
concurrentlyN_
[ do
alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
alice <# "#team cath> hello from cath [>>]",
[ alice <# "#team cath> hello from cath [>>]",
do
dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
dan <# "#team cath> hello from cath [>>]",
@@ -8806,16 +8810,101 @@ testChannelChangeRoleSigned ps =
eve <# "#team cath> hello from cath [>>]"
]
-- change member role (XGrpMemRole) - signed
-- change member role (XGrpMemRole) - signed (other members can verify)
threadDelay 1000000
alice ##> "/mr #team cath admin"
alice <## "#team: you changed the role of cath to admin (signed)"
bob <## "#team: alice changed the role of cath from member to admin (signed)"
concurrentlyN_
[ bob <## "#team: alice changed the role of cath from member to admin (signed)",
cath <## "#team: alice changed your role from member to admin (signed)",
[ cath <## "#team: alice changed your role from member to admin (signed)",
dan <## "#team: alice changed the role of cath from member to admin (signed)",
eve <## "#team: alice changed the role of cath from member to admin (signed)"
]
alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of cath to admin (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")])
cath #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")])
dan #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")])
eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")])
-- change role of silent member (other members don't know about member)
threadDelay 1000000
alice ##> "/mr #team dan admin"
alice <## "#team: you changed the role of dan to admin (signed)"
bob <## "#team: alice changed the role of dan from member to admin (signed)"
concurrentlyN_
[ dan <## "#team: alice changed your role from member to admin (signed)",
cath <## "error: x.grp.mem.role with unknown member ID",
eve <## "error: x.grp.mem.role with unknown member ID"
]
alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of dan to admin (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of dan to admin (signed)")])
cath #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) -- now new chat item
dan #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")])
eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) -- now new chat item
testChannelBlockMemberSigned :: HasCallStack => TestParams -> IO ()
testChannelBlockMemberSigned ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath ->
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
-- other members discover cath
threadDelay 1000000
cath #> "#team hello from cath"
bob <# "#team cath> hello from cath"
concurrentlyN_
[ alice <# "#team cath> hello from cath [>>]",
do
dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
dan <# "#team cath> hello from cath [>>]",
do
eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
eve <# "#team cath> hello from cath [>>]"
]
-- block member (XGrpMemRestrict) - signed (other members can verify)
threadDelay 1000000
alice ##> "/block for all #team cath"
alice <## "#team: you blocked cath (signed)"
bob <## "#team: alice blocked cath (signed)"
concurrentlyN_
[ dan <## "#team: alice blocked cath (signed)",
eve <## "#team: alice blocked cath (signed)"
]
alice #$> ("/_get chat #1 count=1", chat, [(1, "blocked cath (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "blocked cath (signed)")])
cath #$> ("/_get chat #1 count=1", chat, [(1, "hello from cath")]) -- was blocked - no "blocked" chat item
dan #$> ("/_get chat #1 count=1", chat, [(0, "blocked cath (signed)")])
eve #$> ("/_get chat #1 count=1", chat, [(0, "blocked cath (signed)")])
-- TODO [relays] member: in channels - don't create unknown member record and chat item? (just ignore)
-- block silent member (other members create unknown member record and can verify)
threadDelay 1000000
alice ##> "/block for all #team dan"
alice <## "#team: you blocked dan (signed)"
bob <## "#team: alice blocked dan (signed)"
concurrentlyN_
[ do
cath <##. "#team: alice blocked an unknown member, creating unknown member record"
cath .<##. ("#team: alice blocked", "(signed)"),
do
eve <##. "#team: alice blocked an unknown member, creating unknown member record"
eve .<##. ("#team: alice blocked", "(signed)")
]
alice #$> ("/_get chat #1 count=1", chat, [(1, "blocked dan (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "blocked dan (signed)")])
cath ##> "/_get chat #1 count=1"
[(0, r1)] <- chat <$> getTermLine cath
r1 `shouldStartWith` "blocked"
r1 `shouldEndWith` "(signed)"
dan #$> ("/_get chat #1 count=1", chat, [(0, "blocked cath (signed)")]) -- was blocked - no new chat item
eve ##> "/_get chat #1 count=1"
[(0, r2)] <- chat <$> getTermLine eve
r2 `shouldStartWith` "blocked"
r2 `shouldEndWith` "(signed)"
testChannelRemoveMemberSigned :: HasCallStack => TestParams -> IO ()
testChannelRemoveMemberSigned ps =
@@ -8826,13 +8915,11 @@ testChannelRemoveMemberSigned ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
-- discover eve so alice can remove her
-- other members discover eve
eve #> "#team hello from eve"
bob <# "#team eve> hello from eve"
concurrentlyN_
[ do
alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve"
alice <# "#team eve> hello from eve [>>]",
[ alice <# "#team eve> hello from eve [>>]",
do
dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve"
dan <# "#team eve> hello from eve [>>]",
@@ -8841,13 +8928,13 @@ testChannelRemoveMemberSigned ps =
cath <# "#team eve> hello from eve [>>]"
]
-- remove member (XGrpMemDel) - signed
-- remove member (XGrpMemDel) - signed (other members can verify)
threadDelay 1000000
alice ##> "/rm #team eve"
alice <## "#team: you removed eve from the group (signed)"
bob <## "#team: alice removed eve from the group (signed)"
concurrentlyN_
[ bob <## "#team: alice removed eve from the group (signed)",
cath <## "#team: alice removed eve from the group (signed)",
[ cath <## "#team: alice removed eve from the group (signed)",
dan <## "#team: alice removed eve from the group (signed)",
do
eve <## "#team: alice removed you from the group (signed)"
@@ -8855,6 +8942,26 @@ testChannelRemoveMemberSigned ps =
]
alice #$> ("/_get chat #1 count=1", chat, [(1, "removed eve (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "removed eve (signed)")])
cath #$> ("/_get chat #1 count=1", chat, [(0, "removed eve (signed)")])
dan #$> ("/_get chat #1 count=1", chat, [(0, "removed eve (signed)")])
eve #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")])
-- remove silent member (other members don't know about member)
threadDelay 1000000
alice ##> "/rm #team dan"
alice <## "#team: you removed dan from the group (signed)"
bob <## "#team: alice removed dan from the group (signed)"
concurrentlyN_
[ cath <## "error: x.grp.mem.del with unknown member ID",
do
dan <## "#team: alice removed you from the group (signed)"
dan <## "use /d #team to delete the group"
]
alice #$> ("/_get chat #1 count=1", chat, [(1, "removed dan (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "removed dan (signed)")])
cath #$> ("/_get chat #1 count=1", chat, [(0, "removed eve (signed)")]) -- no new chat item
dan #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")])
eve #$> ("/_get chat #1 count=1", chat, [(0, "removed you (signed)")]) -- no new chat item
testChannelDeleteGroupSigned :: HasCallStack => TestParams -> IO ()
testChannelDeleteGroupSigned ps =
@@ -8888,7 +8995,7 @@ testChannelDeleteGroupCleanup ps =
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do
withNewTestChat ps "cath" cathProfile $ \cath -> do
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
memberJoinChannel "team" [bob] shortLink fullLink cath
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
-- verify message delivery works
alice #> "#team hi"
@@ -8913,6 +9020,240 @@ testChannelDeleteGroupCleanup ps =
bob ##> "/groups"
bob <## "#team (group deleted, delete local copy: /d #team)"
testChannelOwnerLeave :: HasCallStack => TestParams -> IO ()
testChannelOwnerLeave ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath ->
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
-- owner leaves channel (XGrpLeave is signed)
threadDelay 1000000
alice ##> "/leave #team"
alice <## "#team: you left the group"
alice <## "use /d #team to delete the group"
bob <## "#team: alice left the group (signed)"
concurrentlyN_
[ cath <## "#team: alice left the group (signed)",
dan <## "#team: alice left the group (signed)",
eve <## "#team: alice left the group (signed)"
]
alice #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")])
cath #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")])
dan #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")])
eve #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")])
testChannelSubscriberLeave :: HasCallStack => TestParams -> IO ()
testChannelSubscriberLeave ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath ->
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
-- other members discover cath
threadDelay 1000000
cath #> "#team hello from cath"
bob <# "#team cath> hello from cath"
concurrentlyN_
[ alice <# "#team cath> hello from cath [>>]",
do
dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
dan <# "#team cath> hello from cath [>>]",
do
eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
eve <# "#team cath> hello from cath [>>]"
]
-- known member leaves (XGrpLeave signed) - owner and relay see items
threadDelay 1000000
cath ##> "/leave #team"
cath <## "#team: you left the group"
cath <## "use /d #team to delete the group"
bob <## "#team: cath left the group (signed)"
alice <## "#team: cath left the group (signed)"
-- other subscribers: cath is known, but items are muted (muteEventInChannel)
-- member status is still updated to left in DB
alice #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")])
cath #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")])
dan #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")]) -- no leave item
eve #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")]) -- no leave item
-- verify cath's member status is "left" on all clients
checkMemberStatus alice "cath" (Just "left")
checkMemberStatus bob "cath" (Just "left")
checkMemberStatus cath "cath" (Just "left")
checkMemberStatus dan "cath" (Just "left")
checkMemberStatus eve "cath" (Just "left")
-- silent subscriber leaves (unknown to other subscribers)
threadDelay 1000000
dan ##> "/leave #team"
dan <## "#team: you left the group"
dan <## "use /d #team to delete the group"
bob <## "#team: dan left the group (signed)"
alice <## "#team: dan left the group (signed)"
-- eve doesn't know dan - no unknown member record created (skipped for XGrpLeave)
alice #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")])
dan #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")])
eve #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")]) -- no new item
-- verify dan's member status is "left" on nodes that know dan
checkMemberStatus alice "dan" (Just "left")
checkMemberStatus bob "dan" (Just "left")
checkMemberStatus dan "dan" (Just "left")
-- eve doesn't know dan - no member record (XGrpLeave skips unknown member creation)
checkMemberStatus eve "dan" Nothing
checkMemberStatus cath "dan" Nothing
where
checkMemberStatus :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO ()
checkMemberStatus cc name expected = do
statuses <- withCCTransaction cc $ \db ->
DB.query db "SELECT member_status FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text]
map (\(Only s) -> s) statuses `shouldBe` maybeToList expected
testChannelOwnerProfileUpdate :: HasCallStack => TestParams -> IO ()
testChannelOwnerProfileUpdate ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath ->
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
-- owner updates profile (XInfo is signed)
-- profile update to group is sent lazily with next message
threadDelay 1000000
alice ##> "/_profile 1 {\"displayName\": \"alisa\", \"fullName\": \"\"}"
alice <## "user profile is changed to alisa (your 0 contacts are notified)"
-- sending as channel does NOT trigger profile update
threadDelay 1000000
alice #> "#team hello from channel"
bob <# "#team> hello from channel"
concurrentlyN_
[ cath <# "#team> hello from channel [>>]",
dan <# "#team> hello from channel [>>]",
eve <# "#team> hello from channel [>>]"
]
-- no profile update items on any participant
alice #$> ("/_get chat #1 count=1", chat, [(1, "hello from channel")])
bob #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "hello from channel")])
cath #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "hello from channel")])
dan #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "hello from channel")])
eve #$> ("/_get chat #1 count=2", chat, [(0, "connected"), (0, "hello from channel")])
-- verify profiles are updated correctly
alice `hasContactProfiles` ["alisa", "bob", "cath", "dan", "eve"]
bob `hasContactProfiles` ["alice", "bob", "cath", "dan", "eve"]
cath `hasContactProfiles` ["alice", "bob", "cath"]
dan `hasContactProfiles` ["alice", "bob", "dan"]
eve `hasContactProfiles` ["alice", "bob", "eve"]
-- sending as member (as_group=off) triggers profile update
threadDelay 1000000
alice ##> "/_send #1(as_group=off) text hello from alisa"
alice <# "#team hello from alisa"
bob <# "#team alisa> hello from alisa"
concurrentlyN_
[ cath <# "#team alisa> hello from alisa [>>]",
dan <# "#team alisa> hello from alisa [>>]",
eve <# "#team alisa> hello from alisa [>>]"
]
-- profile update items on all receivers (signed), not on alice who sent it
alice #$> ("/_get chat #1 count=2", chat, [(1, "hello from channel"), (1, "hello from alisa")])
bob #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from alisa")])
cath #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from alisa")])
dan #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from alisa")])
eve #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from alisa")])
-- verify profiles are updated correctly
forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alisa", "bob", "cath", "dan", "eve"]
cath `hasContactProfiles` ["alisa", "bob", "cath"]
dan `hasContactProfiles` ["alisa", "bob", "dan"]
eve `hasContactProfiles` ["alisa", "bob", "eve"]
testChannelSubscriberProfileUpdate :: HasCallStack => TestParams -> IO ()
testChannelSubscriberProfileUpdate ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath ->
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
-- other members discover cath
threadDelay 1000000
cath #> "#team hello from cath"
bob <# "#team cath> hello from cath"
concurrentlyN_
[ alice <# "#team cath> hello from cath [>>]",
do
dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
dan <# "#team cath> hello from cath [>>]",
do
eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
eve <# "#team cath> hello from cath [>>]"
]
-- known subscriber updates profile (XInfo signed)
threadDelay 1000000
cath ##> "/_profile 1 {\"displayName\": \"kate\", \"fullName\": \"\"}"
cath <## "user profile is changed to kate (your 0 contacts are notified)"
cath #> "#team hello from kate"
bob <# "#team kate> hello from kate"
concurrentlyN_
[ alice <# "#team kate> hello from kate [>>]",
dan <# "#team kate> hello from kate [>>]",
eve <# "#team kate> hello from kate [>>]"
]
-- profile update items on alice and bob (owner/relay, signed)
alice #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from kate")])
bob #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from kate")])
-- no profile update items on dan and eve (subscriber-to-subscriber muted)
dan #$> ("/_get chat #1 count=2", chat, [(0, "hello from cath"), (0, "hello from kate")])
eve #$> ("/_get chat #1 count=2", chat, [(0, "hello from cath"), (0, "hello from kate")])
-- cath doesn't see her own profile update
cath #$> ("/_get chat #1 count=2", chat, [(1, "hello from cath"), (1, "hello from kate")])
-- verify profiles are updated correctly
forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alice", "bob", "kate", "dan", "eve"]
cath `hasContactProfiles` ["alice", "bob", "kate"]
dan `hasContactProfiles` ["alice", "bob", "kate", "dan"]
eve `hasContactProfiles` ["alice", "bob", "kate", "eve"]
-- previously silent subscriber updates profile
threadDelay 1000000
dan ##> "/_profile 1 {\"displayName\": \"dave\", \"fullName\": \"\"}"
dan <## "user profile is changed to dave (your 0 contacts are notified)"
dan #> "#team hello from dave"
bob <# "#team dave> hello from dave"
concurrentlyN_
[ alice <# "#team dave> hello from dave [>>]",
do
eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave"
eve <# "#team dave> hello from dave [>>]",
do
cath <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave"
cath <# "#team dave> hello from dave [>>]"
]
-- profile update items on alice and bob (moderator+/relay, 2nd profile update signed)
alice #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from dave")])
bob #$> ("/_get chat #1 count=2", chat, [(0, "updated profile (signed)"), (0, "hello from dave")])
-- no profile update items on cath and eve (subscriber-to-subscriber muted)
cath #$> ("/_get chat #1 count=2", chat, [(1, "hello from kate"), (0, "hello from dave")])
eve #$> ("/_get chat #1 count=2", chat, [(0, "hello from kate"), (0, "hello from dave")])
-- dan doesn't see his own profile update
dan #$> ("/_get chat #1 count=2", chat, [(0, "hello from kate"), (1, "hello from dave")])
-- verify profiles are updated correctly
forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alice", "bob", "kate", "dave", "eve"]
cath `hasContactProfiles` ["alice", "bob", "kate", "dave"]
dan `hasContactProfiles` ["alice", "bob", "kate", "dave"]
eve `hasContactProfiles` ["alice", "bob", "kate", "dave", "eve"]
testChannelMessageUpdate :: HasCallStack => TestParams -> IO ()
testChannelMessageUpdate ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
@@ -9059,7 +9400,6 @@ testChannelMessageQuote ps =
bob <## " replying to channel"
concurrentlyN_
[ do
alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
alice <# "#team cath> > hello from channel [>>]"
alice <## " replying to channel [>>]",
do
@@ -9422,8 +9762,7 @@ testChannelMemberMessageUpdate ps =
cath #> "#team hello"
bob <# "#team cath> hello"
concurrentlyN_
[ do alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
alice <# "#team cath> hello [>>]",
[ alice <# "#team cath> hello [>>]",
do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
dan <# "#team cath> hello [>>]",
do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
@@ -9454,8 +9793,7 @@ testChannelMemberMessageDelete ps =
cath #> "#team hello"
bob <# "#team cath> hello"
concurrentlyN_
[ do alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
alice <# "#team cath> hello [>>]",
[ alice <# "#team cath> hello [>>]",
do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
dan <# "#team cath> hello [>>]",
do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath"
+10
View File
@@ -375,6 +375,16 @@ cc .<## line = do
unless suffix $ print ("expected to end with: " <> line, ", got: " <> l)
suffix `shouldBe` True
(.<##.) :: HasCallStack => TestCC -> (String, String) -> Expectation
cc .<##. (linePrefix, lineSuffix) = do
l <- getTermLine' (Just $ "prefix: " <> linePrefix <> "; suffix: " <> lineSuffix) cc
let prefix = linePrefix `isPrefixOf` l
unless prefix $ print ("expected to start from: " <> linePrefix, ", got: " <> l)
prefix `shouldBe` True
let suffix = lineSuffix `isSuffixOf` l
unless suffix $ print ("expected to end with: " <> lineSuffix, ", got: " <> l)
suffix `shouldBe` True
(<#.) :: HasCallStack => TestCC -> String -> Expectation
cc <#. line = do
l <- dropTime <$> getTermLine' (Just $ "prefix: " <> line) cc