From 6c06200b1450bd569c7539d8bc0e9f6b06a83fdb Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:00:21 +0000 Subject: [PATCH] core: create business chat when preparing via contact link data with business flag (#6008) * core: create business chat when preparing via contact link data with business flag * add to test * plans * ios * ios --- apps/ios/Shared/Views/Chat/ChatView.swift | 22 +++++--- .../Chat/ComposeMessage/ComposeView.swift | 6 ++- .../Shared/Views/NewChat/NewChatView.swift | 7 ++- src/Simplex/Chat/Library/Commands.hs | 42 ++++++++++----- src/Simplex/Chat/Library/Internal.hs | 12 ++--- src/Simplex/Chat/Store/Groups.hs | 39 ++++++++++---- .../SQLite/Migrations/chat_query_plans.txt | 10 ++++ src/Simplex/Chat/View.hs | 12 ++++- tests/ChatTests/Profiles.hs | 54 +++++++++++++++++++ 9 files changed, 165 insertions(+), 39 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 8e8c621e13..4080605eef 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -767,13 +767,21 @@ struct ChatView: View { } private var connectingText: LocalizedStringKey? { - if let contact = chat.chatInfo.contact, - !contact.sndReady && contact.active && !contact.sendMsgToConnect && !contact.nextAcceptContactRequest { - contact.preparedContact?.uiConnLinkType == .con - ? "contact should accept…" - : "connecting…" - } else { - nil + switch (chat.chatInfo) { + case let .direct(contact): + if !contact.sndReady && contact.active && !contact.sendMsgToConnect && !contact.nextAcceptContactRequest { + contact.preparedContact?.uiConnLinkType == .con + ? "contact should accept…" + : "connecting…" + } else { + nil + } + case let .group(groupInfo, _): + switch (groupInfo.membership.memberStatus) { + case .memAccepted: "connecting…" // TODO [short links] add member status to show transition from prepared group to started connection earlier? + default: nil + } + default: nil } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 57c5421014..8e7b910063 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -402,7 +402,11 @@ struct ComposeView: View { if chat.chatInfo.groupInfo?.nextConnectPrepared == true { Button(action: connectPreparedGroup) { - Label("Join group", systemImage: "person.2.fill") + if chat.chatInfo.groupInfo?.businessChat == nil { + Label("Join group", systemImage: "person.2.fill") + } else { + Label("Connect", systemImage: "briefcase.fill") + } } .frame(height: 60) } else if contact?.nextSendGrpInv == true { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index e33030fd34..d0612f6bd8 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1014,7 +1014,12 @@ private func showPrepareContactAlert( ) { showOpenChatAlert( profileName: contactShortLinkData.profile.displayName, - profileImage: ProfileImage(imageStr: contactShortLinkData.profile.image, size: 60), + profileImage: + ProfileImage( + imageStr: contactShortLinkData.profile.image, + iconName: contactShortLinkData.business ? "briefcase.circle.fill" : "person.crop.circle.fill", + size: 60 + ), theme: theme, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: NSLocalizedString("Open chat", comment: "new chat action"), diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 6dca2fe824..5616fd0f52 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1735,22 +1735,38 @@ processChatCommand' vr = \case pure conn' APIConnectPlan userId cLink -> withUserId userId $ \user -> uncurry (CRConnectionPlan user) <$> connectPlan user cLink - APIPrepareContact userId accLink@(ACCL _ (CCLink cReq _)) contactSLinkData -> withUserId userId $ \user -> do + APIPrepareContact userId accLink contactSLinkData -> withUserId userId $ \user -> do let ContactShortLinkData {profile, message, business} = contactSLinkData - -- TODO [short links] create business contact as group - ct <- withStore $ \db -> createPreparedContact db user profile accLink - let createItem content = createInternalItemForChat user (CDDirectRcv ct) False content Nothing - cInfo = DirectChat ct - void $ createItem $ CIRcvDirectE2EEInfo $ E2EInfo $ connRequestPQEncryption cReq - void $ createFeatureEnabledItems_ user ct - aci <- mapM (createItem . CIRcvMsgContent . MCText) message - let chat = case aci of - Just (AChatItem SCTDirect dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} - _ -> Chat cInfo [] emptyChatStats - pure $ CRNewPreparedChat user $ AChat SCTDirect chat + case accLink of + ACCL SCMContact ccLink + | business -> do + let Profile {preferences} = profile + groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences + groupProfile = businessGroupProfile profile groupPreferences + (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db vr user groupProfile True ccLink + let cd = CDGroupRcv gInfo Nothing hostMember + createItem content = createInternalItemForChat user cd True content Nothing + cInfo = GroupChat gInfo Nothing + void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo + aci <- mapM (createItem . CIRcvMsgContent . MCText) message + let chat = case aci of + Just (AChatItem SCTGroup dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} + _ -> Chat cInfo [] emptyChatStats + pure $ CRNewPreparedChat user $ AChat SCTGroup chat + ACCL _ (CCLink cReq _) -> do + ct <- withStore $ \db -> createPreparedContact db user profile accLink + let createItem content = createInternalItemForChat user (CDDirectRcv ct) False content Nothing + cInfo = DirectChat ct + void $ createItem $ CIRcvDirectE2EEInfo $ E2EInfo $ connRequestPQEncryption cReq + void $ createFeatureEnabledItems_ user ct + aci <- mapM (createItem . CIRcvMsgContent . MCText) message + let chat = case aci of + Just (AChatItem SCTDirect dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} + _ -> Chat cInfo [] emptyChatStats + pure $ CRNewPreparedChat user $ AChat SCTDirect chat APIPrepareGroup userId ccLink groupSLinkData -> withUserId userId $ \user -> do let GroupShortLinkData {groupProfile = gp@GroupProfile {description}} = groupSLinkData - (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db vr user gp ccLink + (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db vr user gp False ccLink let cd = CDGroupRcv gInfo Nothing hostMember createItem content = createInternalItemForChat user cd True content Nothing cInfo = GroupChat gInfo Nothing diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index ddbba0edde..a6cb2e6414 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1023,10 +1023,10 @@ acceptBusinessJoinRequestAsync createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing createGroupFeatureItems user cd CISndGroupFeature gInfo pure (gInfo, clientMember) - where - businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile - businessGroupProfile Profile {displayName, fullName, image} groupPreferences = - GroupProfile {displayName, fullName, description = Nothing, image, groupPreferences = Just groupPreferences, memberAdmission = Nothing} + +businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile +businessGroupProfile Profile {displayName, fullName, image} groupPreferences = + GroupProfile {displayName, fullName, description = Nothing, image, groupPreferences = Just groupPreferences, memberAdmission = Nothing} profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Bool -> Profile profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ip) Nothing @@ -2355,7 +2355,7 @@ sameGroupProfileInfo p p' = p {groupPreferences = Nothing} == p' {groupPreferenc createGroupFeatureItems :: MsgDirectionI d => User -> ChatDirection 'CTGroup d -> (GroupFeature -> GroupPreference -> Maybe Int -> Maybe GroupMemberRole -> CIContent d) -> GroupInfo -> CM () createGroupFeatureItems user cd ciContent g = createGroupFeatureItems_ user cd False ciContent g >>= toView . CEvtNewChatItems user -createGroupFeatureItems_ :: MsgDirectionI d => User -> ChatDirection 'CTGroup d -> Bool -> (GroupFeature -> GroupPreference -> Maybe Int -> Maybe GroupMemberRole -> CIContent d) -> GroupInfo -> CM [AChatItem] +createGroupFeatureItems_ :: MsgDirectionI d => User -> ChatDirection 'CTGroup d -> ShowGroupAsSender -> (GroupFeature -> GroupPreference -> Maybe Int -> Maybe GroupMemberRole -> CIContent d) -> GroupInfo -> CM [AChatItem] createGroupFeatureItems_ user cd showGroupAsSender ciContent GroupInfo {fullGroupPreferences} = forM allGroupFeatures $ \(AGF f) -> do let p = getGroupPreference f fullGroupPreferences @@ -2367,7 +2367,7 @@ createInternalChatItem user cd content itemTs_ = do ci <- createInternalItemForChat user cd False content itemTs_ toView $ CEvtNewChatItems user [ci] -createInternalItemForChat :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> Bool -> CIContent d -> Maybe UTCTime -> CM AChatItem +createInternalItemForChat :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe UTCTime -> CM AChatItem createInternalItemForChat user cd showGroupAsSender content itemTs_ = lift (createInternalItemsForChats user itemTs_ [(cd, showGroupAsSender, [content])]) >>= \case [Right ci] -> pure ci diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 50d48bd7c6..1f3cab0233 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -525,15 +525,16 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) -createPreparedGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> CreatedLinkContact -> ExceptT StoreError IO (GroupInfo, GroupMember) -createPreparedGroup db vr user@User {userId, userContactId} groupProfile connLinkToConnect = do +createPreparedGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> ExceptT StoreError IO (GroupInfo, GroupMember) +createPreparedGroup db vr user@User {userId, userContactId} groupProfile business connLinkToConnect = do currentTs <- liftIO getCurrentTime (groupId, groupLDN) <- createGroup_ db userId groupProfile (Just connLinkToConnect) Nothing currentTs hostMemberId <- insertHost_ currentTs groupId groupLDN let userMember = MemberIdRole (MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id") GRMember - void $ createContactMemberInv_ db user groupId (Just hostMemberId) user userMember GCUserMember GSMemUnknown IBUnknown Nothing currentTs vr - g <- getGroupInfo db vr user groupId + membership <- createContactMemberInv_ db user groupId (Just hostMemberId) user userMember GCUserMember GSMemUnknown IBUnknown Nothing currentTs vr hostMember <- getGroupMember db vr user groupId hostMemberId + when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember + g <- getGroupInfo db vr user groupId pure (g, hostMember) where insertHost_ currentTs groupId groupLDN = do @@ -553,6 +554,23 @@ createPreparedGroup db vr user@User {userId, userContactId} groupProfile connLin :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db + setGroupBusinessChatInfo :: GroupId -> GroupMember -> GroupMember -> IO () + setGroupBusinessChatInfo groupId membership hostMember = do + let businessChatInfo = Just BusinessChatInfo {chatType = BCBusiness, businessId = memberId' hostMember, customerId = memberId' membership} + updateBusinessChatInfo db groupId businessChatInfo + +updateBusinessChatInfo :: DB.Connection -> GroupId -> Maybe BusinessChatInfo -> IO () +updateBusinessChatInfo db groupId businessChatInfo = + DB.execute + db + [sql| + UPDATE groups + SET business_chat = ?, + business_member_id = ?, + customer_member_id = ? + WHERE group_id = ? + |] + (businessChatInfoRow businessChatInfo :. (Only groupId)) updatePreparedGroupUser :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> User -> ExceptT StoreError IO GroupInfo updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMember newUser@User {userId = newUserId} = do @@ -619,33 +637,36 @@ updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMem safeDeleteLDN db user oldHostLDN updatePreparedUserAndHostMembersInvited :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) -updatePreparedUserAndHostMembersInvited db vr user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted} = do +updatePreparedUserAndHostMembersInvited db vr user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do let fromMemberProfile = profileFromName fromMemberName initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted - updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile initialStatus + updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile business initialStatus updatePreparedUserAndHostMembersRejected :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember) updatePreparedUserAndHostMembersRejected db vr user gInfo hostMember GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do let fromMemberProfile = profileFromName $ nameFromMemberId memberId - updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile GSMemRejected + updatePreparedUserAndHostMembers' db vr user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected -updatePreparedUserAndHostMembers' :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +updatePreparedUserAndHostMembers' :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) updatePreparedUserAndHostMembers' db vr user - gInfo@GroupInfo {groupId, groupProfile = gp} + gInfo@GroupInfo {groupId, groupProfile = gp, businessChat} hostMember fromMember fromMemberProfile invitedMember groupProfile + business membershipStatus = do currentTs <- liftIO getCurrentTime liftIO $ updateUserMember currentTs hostMember' <- updateHostMember currentTs when (gp /= groupProfile) $ void $ updateGroupProfile db user gInfo groupProfile + when (isJust businessChat && isJust business) $ + liftIO $ updateBusinessChatInfo db groupId business gInfo' <- getGroupInfo db vr user groupId pure (gInfo', hostMember') where diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index cabf882520..f71f49751b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4562,6 +4562,16 @@ Query: Plan: SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) +Query: + UPDATE groups + SET business_chat = ?, + business_member_id = ?, + customer_member_id = ? + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET members_require_attention = members_require_attention + 1 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 94357ea7a0..2a660345af 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1888,7 +1888,7 @@ viewGroupUserChanged viewConnectionPlan :: ChatConfig -> ACreatedConnLink -> ConnectionPlan -> [StyledString] viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case CPInvitationLink ilp -> case ilp of - ILPOk contactSLinkData -> [invLink "ok to connect"] <> [viewJSON contactSLinkData | testView] + ILPOk contactSLinkData -> [invOrBiz contactSLinkData "ok to connect"] <> [viewJSON contactSLinkData | testView] ILPOwnLink -> [invLink "own link"] ILPConnecting Nothing -> [invLink "connecting"] ILPConnecting (Just ct) -> [invLink ("connecting to contact " <> ttyContact' ct)] @@ -1898,8 +1898,12 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case ] where invLink = ("invitation link: " <>) + invOrBiz = \case + Just ContactShortLinkData {business} + | business -> ("business link: " <>) + _ -> ("invitation link: " <>) CPContactAddress cap -> case cap of - CAPOk contactSLinkData -> [ctAddr "ok to connect"] <> [viewJSON contactSLinkData | testView] + CAPOk contactSLinkData -> [addrOrBiz contactSLinkData "ok to connect"] <> [viewJSON contactSLinkData | testView] CAPOwnLink -> [ctAddr "own address"] CAPConnectingConfirmReconnect -> [ctAddr "connecting, allowed to reconnect"] CAPConnectingProhibit ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] @@ -1910,6 +1914,10 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case CAPContactViaAddress ct -> [ctAddr ("known contact without connection " <> ttyContact' ct)] where ctAddr = ("contact address: " <>) + addrOrBiz = \case + Just ContactShortLinkData {business} + | business -> ("business link: " <>) + _ -> ("contact address: " <>) CPGroupLink glp -> case glp of GLPOk groupSLinkData -> [grpLink "ok to connect"] <> [viewJSON groupSLinkData | testView] GLPOwnLink g -> [grpLink "own link for group " <> ttyGroup' g] diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 3811d15198..43f926db0d 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -114,6 +114,7 @@ chatProfileTests = do it "prepare contact with image in profile" testShortLinkInvitationImage it "prepare contact with a long name in profile" testShortLinkInvitationLongName it "prepare contact using address short link data and connect" testShortLinkAddressPrepareContact + it "prepare business chat using address short link data and connect" testShortLinkAddressPrepareBusiness it "prepare group using group short link data and connect" testShortLinkPrepareGroup it "prepare group using group short link data and connect, host rejects" testShortLinkPrepareGroupReject it "connect to prepared contact incognito (via invitation)" testShortLinkInvitationConnectPreparedContactIncognito @@ -2979,6 +2980,59 @@ testShortLinkAddressPrepareContact = (alice <## "bob (Bob): contact is connected") alice <##> bob +testShortLinkAddressPrepareBusiness :: HasCallStack => TestParams -> IO () +testShortLinkAddressPrepareBusiness = + testChat3 businessProfile aliceProfile {fullName = "Alice @ Biz"} bobProfile $ + \biz alice bob -> do + biz ##> "/ad" + (shortLink, fullLink) <- getContactLinks biz True + biz ##> "/auto_accept on business" + biz <## "auto_accept on, business" + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "business link: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) + bob <## "#biz: group is prepared" + bob ##> "/_connect group #1" + bob <## "#biz: connection started" + biz <## "#bob (Bob): accepting business address request..." + bob <## "#biz: joining the group..." + -- the next command can be prone to race conditions + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "business link: connecting to business #biz" + biz <## "#bob: bob_1 joined the group" + bob <## "#biz: you joined the group" + biz #> "#bob hi" + bob <# "#biz biz_1> hi" + bob #> "#biz hello" + biz <# "#bob bob_1> hello" + + connectUsers biz alice + biz <##> alice + biz ##> "/a #bob alice" + biz <## "invitation to join the group #bob sent to alice" + alice <## "#bob (Bob): biz invites you to join the group as member" + alice <## "use /j bob to accept" + alice ##> "/j bob" + concurrentlyN_ + [ do + alice <## "#bob: you joined the group" + alice <### [WithTime "#bob biz> hi [>>]", WithTime "#bob bob_1> hello [>>]"] + alice <## "#bob: member bob_1 (Bob) is connected", + biz <## "#bob: alice joined the group", + do + bob <## "#biz: biz_1 added alice (Alice @ Biz) to the group (connecting...)" + bob <## "#biz: new member alice is connected" + ] + alice #> "#bob hey" + concurrently_ + (bob <# "#biz alice> hey") + (biz <# "#bob alice> hey") + bob #> "#biz hey there" + concurrently_ + (alice <# "#bob bob_1> hey there") + (biz <# "#bob bob_1> hey there") + testShortLinkPrepareGroup :: HasCallStack => TestParams -> IO () testShortLinkPrepareGroup = testChat3 aliceProfile bobProfile cathProfile $