From 1df5b548a1b81b6a6391794becebf9ce1d32883e Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:54:56 +0000 Subject: [PATCH] invitation to public group in group --- src/Simplex/Chat/Controller.hs | 3 ++- src/Simplex/Chat/Library/Commands.hs | 34 ++++++++++++++------------ src/Simplex/Chat/Library/Subscriber.hs | 27 ++++++++++++-------- src/Simplex/Chat/View.hs | 5 ++++ tests/ChatTests/ChatRelays.hs | 20 +++++++++++++-- 5 files changed, 61 insertions(+), 28 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7393fc78b6..a06945c779 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -519,7 +519,7 @@ data ChatCommand | APIGetGroupRelays {groupId :: GroupId} | NewPublicGroup IncognitoEnabled (NonEmpty Int64) GroupProfile | AddMember GroupName ContactName GroupMemberRole - | SharePublicGroup GroupName ContactName + | SharePublicGroup GroupName ChatName | JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter} | AcceptMember GroupName ContactName GroupMemberRole | MemberRole GroupName ContactName GroupMemberRole @@ -880,6 +880,7 @@ data ChatEvent | CEvtHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} + | CEvtReceivedPublicGroupInvitation {user :: User, sharedGroupInfo :: GroupInfo, contact_ :: Maybe Contact, fromGroupInfo_ :: Maybe GroupInfo, fromMember_ :: Maybe GroupMember} | CEvtUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CEvtGroupLinkDataUpdated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay], relaysChanged :: Bool} | CEvtGroupRelayUpdated {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRelay :: GroupRelay} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 842acec15a..4101493079 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2472,11 +2472,16 @@ processChatCommand vr nm = \case pure (gInfo, relays) pure $ CRGroupRelays user gInfo relays APISharePublicGroup groupId toChatRef -> withUser $ \user -> do - gInfo@GroupInfo {groupProfile, membership, groupSummary, localDisplayName = gName} <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo@GroupInfo {groupProfile, membership = GroupMember {memberId, groupMemberId}, groupSummary, localDisplayName} <- withFastStore $ \db -> getGroupInfo db vr user groupId unless (useRelays' gInfo) $ throwCmdError "not a public group" let groupSize = fromIntegral $ currentMembers groupSummary - GroupMember {memberId = mId, groupMemberId = mGroupMemberId} = membership - ciGroupInv oss = CIGroupInvitation {groupId, groupMemberId = mGroupMemberId, localDisplayName = gName, groupProfile, status = CIGISPending, ownerSigStatus = oss} + sendInv :: forall c. ChatTypeI c => Maybe GroupOwnerSig -> SndMessage -> ChatDirection c 'MDSnd -> ChatInfo c -> CM ChatResponse + sendInv groupOwner msg cd cInfo = do + let oss = if isJust groupOwner then Just OSSVerified else Nothing + ciContent = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, ownerSigStatus = oss}) GRObserver + ci <- saveSndChatItem' user cd msg ciContent Nothing Nothing Nothing Nothing False + toView $ CEvtNewChatItems user [AChatItem chatTypeI SMDSnd cInfo ci] + pure $ CRSentPublicGroupInvitation user gInfo case toChatRef of ChatRef CTDirect contactId _ -> do ct@Contact {activeConn = Just ctConn} <- withFastStore $ \db -> getContact db vr user contactId @@ -2484,13 +2489,10 @@ processChatCommand vr nm = \case adHash <- withAgent $ \a -> getConnectionRatchetAdHash a (aConnId ctConn) let invBody = encodeUtf8 $ encodeJSON groupProfile sig = C.sign' memberPrivKey $ smpEncode CBDirect <> adHash <> invBody - pure GroupOwnerSig {memberId = mId, binding = B64UrlByteString adHash, ownerSig = B64UrlByteString $ C.signatureBytes sig} + pure GroupOwnerSig {memberId, binding = B64UrlByteString adHash, ownerSig = B64UrlByteString $ C.signatureBytes sig} let inv = PublicGroupInvitation {groupProfile, groupOwner, groupSize} - oss = if isJust groupOwner then Just OSSVerified else Nothing (msg, _) <- sendDirectContactMessage user ct $ XGrpInvPub inv - ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndGroupInvitation (ciGroupInv oss) GRObserver) Nothing Nothing Nothing Nothing False - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] - pure $ CRSentPublicGroupInvitation user gInfo + sendInv groupOwner msg (CDDirectSnd ct) (DirectChat ct) ChatRef CTGroup toGroupId scope -> do (toGInfo, members) <- withFastStore $ \db -> do g <- getGroupInfo db vr user toGroupId @@ -2498,9 +2500,7 @@ processChatCommand vr nm = \case pure (g, ms) let inv = PublicGroupInvitation {groupProfile, groupOwner = Nothing, groupSize} msg <- sendGroupMessage user toGInfo scope members $ XGrpInvPub inv - ci <- saveSndChatItem' user (CDGroupSnd toGInfo Nothing) msg (CISndGroupInvitation (ciGroupInv Nothing) GRObserver) Nothing Nothing Nothing Nothing False - toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat toGInfo Nothing) ci] - pure $ CRSentPublicGroupInvitation user gInfo + sendInv Nothing msg (CDGroupSnd toGInfo Nothing) (GroupChat toGInfo Nothing) _ -> throwCmdError "unsupported chat type" APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do -- TODO for large groups: no need to load all members to determine if contact is a member @@ -2889,9 +2889,13 @@ processChatCommand vr nm = \case AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand vr nm $ APIAddMember groupId contactId memRole - SharePublicGroup gName cName -> withUser $ \user -> do - (groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName - processChatCommand vr nm $ APISharePublicGroup groupId (ChatRef CTDirect contactId Nothing) + SharePublicGroup gName (ChatName cType toName) -> withUser $ \user -> do + groupId <- withFastStore $ \db -> getGroupIdByName db user gName + chatRef <- case cType of + CTDirect -> ChatRef CTDirect <$> withFastStore (\db -> getContactIdByName db user toName) <*> pure Nothing + CTGroup -> ChatRef CTGroup <$> withFastStore (\db -> getGroupIdByName db user toName) <*> pure Nothing + _ -> throwCmdError "unsupported chat type" + processChatCommand vr nm $ APISharePublicGroup groupId chatRef JoinGroup gName enableNtfs -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand vr nm $ APIJoinGroup groupId enableNtfs @@ -4845,7 +4849,7 @@ chatCommandP = "/_ntf conn messages " *> (APIGetConnNtfMessages <$> connMsgsP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_share #" *> (APISharePublicGroup <$> A.decimal <* A.space <*> chatRefP), - "/share " *> char_ '#' *> (SharePublicGroup <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/share " *> char_ '#' *> (SharePublicGroup <$> displayNameP <* A.space <*> chatNameP), "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI "/_accept member #" *> (APIAcceptMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_delete member chat #" *> (APIDeleteMemberSupportChat <$> A.decimal <* A.space <*> A.decimal), diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 22bf21ca9b..a612efb0fc 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1029,6 +1029,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XInfoProbe probe -> Nothing <$ xInfoProbe (COMGroupMember m'') probe XInfoProbeCheck probeHash -> Nothing <$ xInfoProbeCheck (COMGroupMember m'') probeHash XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe + XGrpInvPub pubGrpInv -> Nothing <$ processPublicGroupInvitationGroup gInfo' m'' conn' pubGrpInv msg brokerTs BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) forM deliveryTaskContext_ $ \taskContext -> @@ -2317,6 +2318,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ft <- withStore $ \db -> getDirectFileIdBySharedMsgId db user ct sharedMsgId >>= getRcvFileTransfer db user receiveInlineChunk ft chunk meta + processPublicGroupInvitationGroup :: GroupInfo -> GroupMember -> Connection -> PublicGroupInvitation -> RcvMessage -> UTCTime -> CM () + processPublicGroupInvitationGroup g m conn inv msg brokerTs = do + (gInfo, _) <- rcvPublicGroupInvitation conn inv msg brokerTs (CDGroupRcv g Nothing m) (GroupChat g Nothing) + toView $ CEvtReceivedPublicGroupInvitation {user, sharedGroupInfo = gInfo, contact_ = Nothing, fromGroupInfo_ = Just g, fromMember_ = Just m} + bFileChunkGroup :: GroupInfo -> SharedMsgId -> FileChunk -> MsgMeta -> CM () bFileChunkGroup GroupInfo {groupId} sharedMsgId chunk meta = do ft <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId >>= getRcvFileTransfer db user @@ -2415,7 +2421,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sameGroupLinkId _ _ = False processPublicGroupInvitation :: Contact -> Connection -> PublicGroupInvitation -> RcvMessage -> MsgMeta -> CM () - processPublicGroupInvitation ct conn PublicGroupInvitation {groupProfile, groupOwner, groupSize} msg msgMeta = do + processPublicGroupInvitation ct conn inv msg msgMeta = do + (gInfo, _) <- rcvPublicGroupInvitation conn inv msg (metaBrokerTs msgMeta) (CDDirectRcv ct) (DirectChat ct) + toView $ CEvtReceivedPublicGroupInvitation {user, sharedGroupInfo = gInfo, contact_ = Just ct, fromGroupInfo_ = Nothing, fromMember_ = Nothing} + + rcvPublicGroupInvitation :: forall c. (ChatTypeI c, ChatTypeQuotable c) => Connection -> PublicGroupInvitation -> RcvMessage -> UTCTime -> ChatDirection c 'MDRcv -> ChatInfo c -> CM (GroupInfo, ChatItem c 'MDRcv) + rcvPublicGroupInvitation conn PublicGroupInvitation {groupProfile, groupOwner, groupSize} msg brokerTs cd cInfo = do unless (isJust $ publicGroup groupProfile) $ messageError "x.grp.inv.pub: not a public group" let oss = case groupOwner of Nothing -> Nothing @@ -2424,21 +2435,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = prepLink = PreparedConnLink {connFullLink = Nothing, connShortLink = sLnk_} gVar <- asks random subRole <- asks $ channelSubscriberRole . config - (gInfo@GroupInfo {groupId, localDisplayName = gName, membership}, _) <- withStore $ \db -> + (gInfo@GroupInfo {groupId, localDisplayName, membership = GroupMember {groupMemberId}}, _) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile False prepLink Nothing True subRole (Just $ fromIntegral groupSize) withStore' $ \db -> setGroupChatHidden db user groupId True - let GroupMember {groupMemberId} = membership - content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName = gName, groupProfile, status = CIGISPending, ownerSigStatus = oss}) GRObserver - (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, ownerSigStatus = oss}) GRObserver + (ci, _) <- saveRcvChatItemNoParse user cd msg brokerTs content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] - toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = GROwner, memberRole = GRObserver} - -- start async link resolution for signature verification + toView $ CEvtNewChatItems user [AChatItem chatTypeI SMDRcv cInfo ci] forM_ groupOwner $ \_ -> forM_ sLnk_ $ \sLnk -> void $ getAgentConnShortLinkAsync user CFGetGroupDataInv (Just conn) sLnk - where - brokerTs = metaBrokerTs msgMeta + pure (gInfo, ci) processPublicGroupLinkData :: User -> Contact -> Connection -> FixedLinkData 'CMContact -> ConnLinkData 'CMContact -> CM () processPublicGroupLinkData _user _ct _conn _fixedLinkData _cData = do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ab46713991..46b602f159 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -472,6 +472,11 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} in ttyUser u [sShow connId <> ": END"] CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> show (length conns) <> " connections on server " <> showSMPServer srv] CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r + CEvtReceivedPublicGroupInvitation {user = u, sharedGroupInfo = g, contact_ = ct_, fromGroupInfo_ = fromG_, fromMember_ = m_} -> + ttyUser u $ case (ct_, fromG_, m_) of + (Just ct, _, _) -> [ttyFullContact ct <> " shared public group " <> ttyGroup' g] + (_, Just fromG, Just m) -> [ttyGroup' fromG <> ": " <> ttyMember m <> " shared public group " <> ttyGroup' g] + _ -> ["received public group invitation " <> ttyGroup' g] CEvtUserJoinedGroup u g m -> ttyUser u $ viewUserJoinedGroup g m CEvtGroupLinkDataUpdated u g groupLink relays relaysChanged | relaysChanged -> ttyUser u $ viewGroupLinkRelaysUpdated g groupLink relays diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 175be949b7..0c10a9c3ea 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -16,6 +16,7 @@ chatRelayTests = do it "relay profile updated in address" testRelayProfileUpdateInAddress describe "public group invitations" $ do it "share public group in direct chat" testSharePublicGroupDirect + it "share public group in group chat" testSharePublicGroupInGroup testGetSetChatRelays :: HasCallStack => TestParams -> IO () testGetSetChatRelays ps = @@ -180,8 +181,23 @@ testSharePublicGroupDirect ps = connectUsers alice cath alice ##> "/share #team @cath" alice <## "shared public group #team" - cath <## "#team: alice invites you to join the group as observer" - cath <## "use /j team to accept" + cath <## "alice (Alice) shared public group #team" + +testSharePublicGroupInGroup :: HasCallStack => TestParams -> IO () +testSharePublicGroupInGroup ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + alice ##> ("/relays name=bob_relay " <> bobSLink) + alice <## "ok" + createChannelWithRelay "team" alice bob + -- create a regular group with alice and cath + createGroup2 "friends" alice cath + alice ##> "/share #team #friends" + alice <## "shared public group #team" + cath <## "#friends: alice shared public group #team" -- Create a public group with relay=1, wait for relay to join createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO ()