diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 28301c5ddb..6bc3a221b2 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -16,6 +16,8 @@ struct UserAddressView: View { @EnvironmentObject var theme: AppTheme @State var shareViaProfile = false @State var autoCreate = false + @State private var aas = AutoAcceptState() + @State private var savedAAS = AutoAcceptState() @State private var showMailView = false @State private var mailViewResult: Result? = nil @State private var alert: UserAddressAlert? @@ -55,7 +57,15 @@ struct UserAddressView: View { if chatModel.userAddress == nil, autoCreate { createAddress() } + if let userAddress = chatModel.userAddress { + aas = AutoAcceptState(userAddress: userAddress) + savedAAS = aas + } } + .onChange(of: aas.enable) { aasEnabled in + if !aasEnabled { aas = AutoAcceptState() } + } + } private func userAddressView() -> some View { @@ -135,10 +145,23 @@ struct UserAddressView: View { // if MFMailComposeViewController.canSendMail() { // shareViaEmailButton(userAddress) // } + settingsRow("hand.wave", color: theme.colors.secondary) { + Toggle("Business address", isOn: $aas.business) + .onChange(of: aas.business) { ba in + if ba { + aas.enable = true + aas.incognito = false + } + } + } addressSettingsButton(userAddress) } header: { Text("For social media") .foregroundColor(theme.colors.secondary) + } footer: { + if aas.business { + Text("Add your team members to the conversations").foregroundColor(theme.colors.secondary) + } } Section { @@ -276,11 +299,13 @@ struct UserAddressView: View { private struct AutoAcceptState: Equatable { var enable = false var incognito = false + var business = false var welcomeText = "" - init(enable: Bool = false, incognito: Bool = false, welcomeText: String = "") { + init(enable: Bool = false, incognito: Bool = false, business: Bool = false, welcomeText: String = "") { self.enable = enable self.incognito = incognito + self.business = business self.welcomeText = welcomeText } @@ -288,6 +313,7 @@ private struct AutoAcceptState: Equatable { if let aa = userAddress.autoAccept { enable = true incognito = aa.acceptIncognito + business = aa.businessAddress == true if let msg = aa.autoReply { welcomeText = msg.text } else { @@ -296,6 +322,7 @@ private struct AutoAcceptState: Equatable { } else { enable = false incognito = false + business = false welcomeText = "" } } @@ -305,7 +332,7 @@ private struct AutoAcceptState: Equatable { var autoReply: MsgContent? = nil let s = welcomeText.trimmingCharacters(in: .whitespacesAndNewlines) if s != "" { autoReply = .text(s) } - return AutoAccept(acceptIncognito: incognito, autoReply: autoReply) + return AutoAccept(businessAddress: business, acceptIncognito: incognito, autoReply: autoReply) } return nil } @@ -373,7 +400,7 @@ struct UserAddressSettingsView: View { List { Section { shareWithContactsButton() - autoAcceptToggle() + autoAcceptToggle().disabled(aas.business) } if aas.enable { @@ -450,7 +477,9 @@ struct UserAddressSettingsView: View { private func autoAcceptSection() -> some View { Section { - acceptIncognitoToggle() + if !aas.business { + acceptIncognitoToggle() + } welcomeMessageEditor() saveAASButton() .disabled(aas == savedAAS) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 31917f1ab9..7e5a48013b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -229,6 +229,7 @@ D741547A29AF90B00022400A /* PushKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547929AF90B00022400A /* PushKit.framework */; }; D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; }; D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; }; + E504516F2CFA3BFB00DE3F74 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E504516E2CFA3BFB00DE3F74 /* ContextMenu.swift */; }; E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; @@ -575,6 +576,7 @@ D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + E504516E2CFA3BFB00DE3F74 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = ""; }; E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = ""; }; E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; @@ -791,6 +793,7 @@ 5C971E1F27AEBF7000C8A3CE /* Helpers */ = { isa = PBXGroup; children = ( + E504516E2CFA3BFB00DE3F74 /* ContextMenu.swift */, 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */, 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */, 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */, @@ -1445,6 +1448,7 @@ CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */, 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, + E504516F2CFA3BFB00DE3F74 /* ContextMenu.swift in Sources */, 5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */, 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */, 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 83c74178ba..954022c312 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -2103,10 +2103,12 @@ public struct UserContactLink: Decodable, Hashable { } public struct AutoAccept: Codable, Hashable { + public var businessAddress: Bool? // make not nullable public var acceptIncognito: Bool public var autoReply: MsgContent? - public init(acceptIncognito: Bool, autoReply: MsgContent? = nil) { + public init(businessAddress: Bool, acceptIncognito: Bool, autoReply: MsgContent? = nil) { + self.businessAddress = businessAddress self.acceptIncognito = acceptIncognito self.autoReply = autoReply } diff --git a/cabal.project b/cabal.project index 2246cfeb1d..b89dc764cb 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 601620bdde612ebdd33da2637d99b15ff32170c9 + tag: 38ad3c046e1bd5eb1ffe696dd24b10dd69001ba2 source-repository-package type: git diff --git a/docs/rfcs/2024-11-28-business-address.md b/docs/rfcs/2024-11-28-business-address.md new file mode 100644 index 0000000000..e41c904457 --- /dev/null +++ b/docs/rfcs/2024-11-28-business-address.md @@ -0,0 +1,29 @@ +# Business address + +## Problem + +When business uses a communication system for support and other business scenarios, it's important for the customer: +- to be able to talk to multiple people in the business, and know who they are. +- potentially, add friends or relatives to the conversation if this is about a group purchase. + +It's important for the business: +- to have bot accept incoming requests. +- to be able to add other people to the coversation, as transfer and as escalation. + +This is how all messaging support system works, and how WeChat business accounts work, but no messenger provides it. + +## Solution + +Make current contact addresses to support business mode. We already have all the elements for that. + +- connection requests will be accepted automatically (non-optionally), and auto-reply will be sent (if provided). +- the request sender will be made member, can be made admin later manually. +- the new group with the customer will be created on each request instead of direct conversation. + +Group will function differently from a normal group: +- Show business name and avatar to customer, customer name and avatar to business. +- Use different icon for customer and for the business if the avatar is not provided. +- Possibly, a sub-icon on business avatar for customers. +- Members added by business are marked as business, by customer as customer (not MVP). + +This functionality allows to develop support bots that automatically reply, potentially answer some questions, and add support agents as required, who can escalate further. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 72d2ddd59b..7a812dbc6e 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."601620bdde612ebdd33da2637d99b15ff32170c9" = "0lgiphb9sf5i29d378pah24mhf7m8df75jk6asvw8ns527g4amj1"; + "https://github.com/simplex-chat/simplexmq.git"."38ad3c046e1bd5eb1ffe696dd24b10dd69001ba2" = "0nq2a2lklbxpc049zjxa5w8c63l9l9nf08jb7pny42nmah0mlc20"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 1b1a0b9753..4e339fc5da 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -153,6 +153,7 @@ library Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id Simplex.Chat.Migrations.M20241027_server_operators Simplex.Chat.Migrations.M20241125_indexes + Simplex.Chat.Migrations.M20241128_business_chats Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index cc7fc992fb..92ec04d11b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2065,6 +2065,8 @@ processChatCommand' vr = \case SetProfileAddress onOff -> withUser $ \User {userId} -> processChatCommand $ APISetProfileAddress userId onOff APIAddressAutoAccept userId autoAccept_ -> withUserId userId $ \user -> do + forM_ autoAccept_ $ \AutoAccept {businessAddress, acceptIncognito} -> + when (businessAddress && acceptIncognito) $ throwChatError $ CECommandError "requests to business address cannot be accepted incognito" contactLink <- withFastStore (\db -> updateUserAddressAutoAccept db user autoAccept_) pure $ CRUserContactLinkUpdated user contactLink AddressAutoAccept autoAccept_ -> withUser $ \User {userId} -> @@ -3007,7 +3009,7 @@ processChatCommand' vr = \case groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName pure (groupId, groupMemberId) sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> CM () - sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let GroupMember {memberRole = userRole, memberId = userMemberId} = membership groupInv = @@ -3016,6 +3018,7 @@ processChatCommand' vr = \case invitedMember = MemberIdRole memberId memRole, connRequest = cReq, groupProfile, + businessChat, groupLinkId = Nothing, groupSize = Just currentMemCount } @@ -3972,12 +3975,14 @@ acceptContactRequestAsync user cReq@UserContactRequest {agentInvitationId = Agen acceptGroupJoinRequestAsync :: User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember acceptGroupJoinRequestAsync user - gInfo@GroupInfo {groupProfile, membership} + gInfo@GroupInfo {groupProfile, membership, businessChat} ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} gLinkMemRole incognitoProfile = do gVar <- asks random - (groupMemberId, memberId) <- withStore $ \db -> createAcceptedMember db gVar user gInfo ucr gLinkMemRole + (groupMemberId, memberId) <- withStore $ \db -> do + liftIO $ deleteContactRequestRec db user ucr + createAcceptedMember db gVar user gInfo ucr gLinkMemRole currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let Profile {displayName} = profileToSendOnAccept user incognitoProfile True GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -3988,6 +3993,7 @@ acceptGroupJoinRequestAsync fromMemberName = displayName, invitedMember = MemberIdRole memberId gLinkMemRole, groupProfile, + businessChat, groupSize = Just currentMemCount } subMode <- chatReadVar subscriptionMode @@ -3998,6 +4004,43 @@ acceptGroupJoinRequestAsync liftIO $ createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode getGroupMemberById db vr user groupMemberId +acceptBusinessJoinRequestAsync :: User -> UserContactRequest -> CM GroupInfo +acceptBusinessJoinRequestAsync + user + ucr@UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange} = do + vr <- chatVersionRange + gVar <- asks random + let userProfile@Profile {displayName, preferences} = profileToSendOnAccept user Nothing True + groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences + (gInfo, clientMember) <- withStore $ \db -> do + liftIO $ deleteContactRequestRec db user ucr + createBusinessRequestGroup db vr gVar user ucr groupPreferences + let GroupInfo {membership} = gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + GroupMember {groupMemberId, memberId} = clientMember + msg = + XGrpLinkInv $ + GroupLinkInvitation + { fromMember = MemberIdRole userMemberId userRole, + fromMemberName = displayName, + invitedMember = MemberIdRole memberId GRMember, + groupProfile = businessGroupProfile userProfile groupPreferences, + -- This refers to the "title member" that defines the group name and profile. + -- This coincides with fromMember to be current user when accepting the connecting user, + -- but it will be different when inviting somebody else. + businessChat = Just $ BusinessChatInfo userMemberId BCBusiness, + groupSize = Just 1 + } + subMode <- chatReadVar subscriptionMode + let chatV = vr `peerConnChatVersion` cReqChatVRange + connIds <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV + withStore' $ \db -> createAcceptedMemberConnection db user connIds chatV ucr groupMemberId subMode + pure gInfo + where + businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile + businessGroupProfile Profile {displayName, fullName, image} groupPreferences = + GroupProfile {displayName, fullName, description = Nothing, image, groupPreferences = Just groupPreferences} + profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Bool -> Profile profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ip) Nothing where @@ -4683,15 +4726,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CONF confId pqSupport _ connInfo -> do conn' <- processCONFpqSupport conn pqSupport -- [incognito] send saved profile + (conn'', inGroup) <- saveConnInfo conn' connInfo incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing False - conn'' <- saveConnInfo conn' connInfo + let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing inGroup -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do processINFOpqSupport conn pqSupport - _conn' <- saveConnInfo conn connInfo - pure () + void $ saveConnInfo conn connInfo MSG meta _msgFlags _msgBody -> -- We are not saving message (saveDirectRcvMSG) as contact hasn't been created yet, -- chat item is also not created here @@ -4806,6 +4848,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let p = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') False allowAgentConnectionAsync user conn'' confId $ XInfo p void $ withStore' $ \db -> resetMemberContactFields db ct' + XGrpLinkInv glInv -> do + -- XGrpLinkInv here means we are connecting via business contact card, so we replace contact with group + (gInfo, host) <- withStore $ \db -> do + liftIO $ deleteContactCardKeepConn db connId ct + createGroupInvitedViaLink db vr user conn'' glInv + -- [incognito] send saved profile + incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) + let profileToSend = userProfileToSend user (fromLocalProfile <$> incognitoProfile) Nothing True + allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend + toView $ CRBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" INFO pqSupport connInfo -> do processINFOpqSupport conn pqSupport @@ -4936,7 +4988,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> CM () - processGroupMessage agentMsg connEntity conn@Connection {connId, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of + processGroupMessage agentMsg connEntity conn@Connection {connId, connChatVersion, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of INV (ACR _ cReq) -> withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> case cReq of @@ -4977,6 +5029,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = invitedMember = MemberIdRole memberId memRole, connRequest = cReq, groupProfile, + businessChat = Nothing, groupLinkId = groupLinkId, groupSize = Just currentMemCount } @@ -5049,6 +5102,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m sendIntroductions members when (groupFeatureAllowed SGFHistory gInfo) sendHistory + when (connChatVersion < batchSend2Version) $ sendGroupAutoReply members where sendXGrpLinkMem = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo @@ -5311,9 +5365,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = OK -> -- [async agent commands] continuation on receiving OK when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - JOINED _ -> + JOINED sqSecured -> -- [async agent commands] continuation on receiving JOINED - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () + when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> + when sqSecured $ do + members <- withStore' $ \db -> getGroupMembers db vr user gInfo + when (connChatVersion >= batchSend2Version) $ sendGroupAutoReply members QCONT -> do continued <- continueSending connEntity conn when continued $ sendPendingGroupMessages user m conn @@ -5341,6 +5398,23 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupItemsErrorStatus db msgId groupMemberId newStatus = do itemIds <- getChatItemIdsByAgentMsgId db connId msgId forM_ itemIds $ \itemId -> updateGroupMemSndStatus' db itemId groupMemberId newStatus + sendGroupAutoReply members = autoReplyMC >>= mapM_ send + where + autoReplyMC = do + let GroupInfo {businessChat} = gInfo + GroupMember {memberId = joiningMemberId} = m + case businessChat of + Just BusinessChatInfo {memberId, chatType = BCCustomer} + | joiningMemberId == memberId -> useReply <$> withStore (`getUserAddress` user) + where + useReply UserContactLink {autoAccept} = case autoAccept of + Just AutoAccept {businessAddress, autoReply} | businessAddress -> autoReply + _ -> Nothing + _ -> pure Nothing + send mc = do + msg <- sendGroupMessage' user gInfo members (XMsgNew $ MCSimple (extMsgContent mc Nothing)) + ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent mc) + toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] agentMsgDecryptError :: AgentCryptoError -> (MsgDecryptError, Word32) agentMsgDecryptError = \case @@ -5525,26 +5599,37 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq -> do ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {autoAccept}, groupId_, gLinkMemRole) = ucl + let (UserContactLink {connReqContact, autoAccept}, groupId_, gLinkMemRole) = ucl + isSimplexTeam = sameConnReqContact connReqContact adminContactReq + v = maxVersion chatVRange case autoAccept of - Just AutoAccept {acceptIncognito} -> case groupId_ of - Nothing -> do - -- [incognito] generate profile to send, create connection with incognito profile - incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequestAsync user cReq incognitoProfile True reqPQSup - toView $ CRAcceptingContactRequest user ct - Just groupId -> do - gInfo <- withStore $ \db -> getGroupInfo db vr user groupId - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - if maxVersion chatVRange >= groupFastLinkJoinVersion - then do - mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode - createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing - toView $ CRAcceptingGroupJoinRequestMember user gInfo mem - else do - -- TODO v5.7 remove old API (or v6.0?) - ct <- acceptContactRequestAsync user cReq profileMode False PQSupportOff - toView $ CRAcceptingGroupJoinRequest user gInfo ct + Just AutoAccept {acceptIncognito, businessAddress} + | businessAddress -> + if v < groupFastLinkJoinVersion || (isSimplexTeam && v < businessChatsVersion) + then do + ct <- acceptContactRequestAsync user cReq Nothing True reqPQSup + toView $ CRAcceptingContactRequest user ct + else do + gInfo <- acceptBusinessJoinRequestAsync user cReq + toView $ CRAcceptingBusinessRequest user gInfo + | otherwise -> case groupId_ of + Nothing -> do + -- [incognito] generate profile to send, create connection with incognito profile + incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + ct <- acceptContactRequestAsync user cReq incognitoProfile True reqPQSup + toView $ CRAcceptingContactRequest user ct + Just groupId -> do + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + if v >= groupFastLinkJoinVersion + then do + mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode + createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CRAcceptingGroupJoinRequestMember user gInfo mem + else do + -- TODO v5.7 remove old API (or v6.0?) + ct <- acceptContactRequestAsync user cReq profileMode False PQSupportOff + toView $ CRAcceptingGroupJoinRequest user gInfo ct _ -> toView $ CRReceivedContactRequest user cReq memberCanSend :: GroupMember -> CM () -> CM () @@ -6353,9 +6438,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xInfoMember gInfo m p' brokerTs = void $ processMemberProfileUpdate gInfo m p' True (Just brokerTs) xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> CM () - xGrpLinkMem gInfo@GroupInfo {membership} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do + xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId - if viaGroupLink && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived + if (viaGroupLink || isJust businessChat) && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived then do m' <- processMemberProfileUpdate gInfo m p' False Nothing withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True @@ -6652,7 +6737,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRContactAndMemberAssociated user c2 g m1 c2' pure c2' - saveConnInfo :: Connection -> ConnInfo -> CM Connection + saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Bool) saveConnInfo activeConn connInfo = do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo conn' <- updatePeerChatVRange activeConn chatVRange @@ -6661,13 +6746,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let contactUsed = connDirect activeConn ct <- withStore $ \db -> createDirectContact db user conn' p contactUsed toView $ CRContactConnecting user ct - pure conn' + pure (conn', False) XGrpLinkInv glInv -> do (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv toView $ CRGroupLinkConnecting user gInfo host - pure conn' + pure (conn', True) -- TODO show/log error, other events in SMP confirmation - _ -> pure conn' + _ -> pure (conn', False) xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> CM () xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msg brokerTs = do @@ -8683,11 +8768,11 @@ chatCommandP = dbKeyP = nonEmptyKey <$?> strP nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k dbEncryptionConfig currentKey newKey = DBEncryptionConfig {currentKey, newKey, keepKey = Just False} - autoAcceptP = - ifM - onOffP - (Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP))) - (pure Nothing) + autoAcceptP = ifM onOffP (Just <$> (businessAA <|> addressAA)) (pure Nothing) + where + addressAA = AutoAccept False <$> (" incognito=" *> onOffP <|> pure False) <*> autoReply + businessAA = AutoAccept True <$> (" business" *> pure False) <*> autoReply + autoReply = optional (A.space *> msgContentP) rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P)) text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 8c0978a98f..2f7e2f2abd 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -56,7 +56,7 @@ initializeBotAddress' logAddress cc = do where showBotAddress uri = do when logAddress $ putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri) - void $ sendChatCmd cc $ AddressAutoAccept $ Just AutoAccept {acceptIncognito = False, autoReply = Nothing} + void $ sendChatCmd cc $ AddressAutoAccept $ Just AutoAccept {businessAddress = False, acceptIncognito = False, autoReply = Nothing} sendMessage :: ChatController -> Contact -> Text -> IO () sendMessage cc ct = sendComposedMessage cc ct Nothing . MCText diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d208efce77..0ab3ba5652 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -639,6 +639,7 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} + | CRBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact} | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} @@ -665,6 +666,7 @@ data ChatResponse | CRUserContactLinkDeleted {user :: User} | CRReceivedContactRequest {user :: User, contactRequest :: UserContactRequest} | CRAcceptingContactRequest {user :: User, contact :: Contact} + | CRAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} | CRContactAlreadyExists {user :: User, contact :: Contact} | CRContactRequestAlreadyAccepted {user :: User, contact :: Contact} | CRLeftMemberUser {user :: User, groupInfo :: GroupInfo} diff --git a/src/Simplex/Chat/Migrations/M20241128_business_chats.hs b/src/Simplex/Chat/Migrations/M20241128_business_chats.hs new file mode 100644 index 0000000000..f068b1bd81 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241128_business_chats.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20241128_business_chats where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241128_business_chats :: Query +m20241128_business_chats = + [sql| +ALTER TABLE user_contact_links ADD business_address INTEGER DEFAULT 0; +ALTER TABLE groups ADD COLUMN business_member_id BLOB NULL; +ALTER TABLE groups ADD COLUMN business_chat TEXT NULL; +|] + +down_m20241128_business_chats :: Query +down_m20241128_business_chats = + [sql| +ALTER TABLE user_contact_links DROP COLUMN business_address; +ALTER TABLE groups DROP COLUMN business_member_id; +ALTER TABLE groups DROP COLUMN business_chat; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 6f944157c1..460b348b4d 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -127,7 +127,9 @@ CREATE TABLE groups( via_group_link_uri_hash BLOB, user_member_profile_sent_at TEXT, custom_data BLOB, - ui_themes TEXT, -- received + ui_themes TEXT, + business_member_id BLOB NULL, + business_chat TEXT NULL, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -309,6 +311,7 @@ CREATE TABLE user_contact_links( auto_accept_incognito INTEGER DEFAULT 0 CHECK(auto_accept_incognito NOT NULL), group_link_id BLOB, group_link_member_role TEXT NULL, + business_address INTEGER DEFAULT 0, UNIQUE(user_id, local_display_name) ); CREATE TABLE contact_requests( diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index ea39293b9f..8afefdc850 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -66,12 +66,13 @@ import Simplex.Messaging.Version hiding (version) -- 7 - update member profiles (1/15/2024) -- 8 - compress messages and PQ e2e encryption (2024-03-08) -- 9 - batch sending in direct connections (2024-07-24) +-- 10 - business chats (2024-11-29) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 9 +currentChatVersion = VersionChat 10 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -110,6 +111,10 @@ pqEncryptionCompressionVersion = VersionChat 8 batchSend2Version :: VersionChat batchSend2Version = VersionChat 9 +-- supports differentiating business chats when joining contact addresses +businessChatsVersion :: VersionChat +businessChatsVersion = VersionChat 10 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 2c7543f08a..fe52c6d7b7 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -123,7 +123,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_member_id, g.business_chat, g.ui_themes, g.custom_data, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 142c702f77..b07adf407b 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -30,6 +30,7 @@ module Simplex.Chat.Store.Groups getGroupAndMember, createNewGroup, createGroupInvitation, + deleteContactCardKeepConn, createGroupInvitedViaLink, setViaGroupLinkHash, setGroupInvitationChatItemId, @@ -62,6 +63,7 @@ module Simplex.Chat.Store.Groups createNewContactMemberAsync, createAcceptedMember, createAcceptedMemberConnection, + createBusinessRequestGroup, getContactViaMember, setNewContactMemberConnRequest, getMemberInvitation, @@ -153,19 +155,20 @@ import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime, Maybe UIThemeEntityOverrides, Maybe CustomData) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime, Maybe MemberId, Maybe BusinessChatType, Maybe UIThemeEntityOverrides, Maybe CustomData) :. GroupMemberRow type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) toGroupInfo :: VersionRangeChat -> Int64 -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt, uiThemes, customData) :. userMemberRow) = +toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt, businessMemberId, businessChatType, uiThemes, customData) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} - in GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, uiThemes, customData} + businessChat = BusinessChatInfo <$> businessMemberId <*> businessChatType + in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, uiThemes, customData} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = @@ -276,7 +279,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_member_id, g.business_chat, g.ui_themes, g.custom_data, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -342,6 +345,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc { groupId, localDisplayName = ldn, groupProfile, + businessChat = Nothing, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, @@ -357,7 +361,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc -- | creates a new group record for the group the current user was invited to, or returns an existing one createGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId, peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do +createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId, peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, businessChat} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do @@ -395,10 +399,10 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ [sql| INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at) - VALUES (?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_member_id, business_chat) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) + ((profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) :. businessChatTuple businessChat) insertedRowId db let hostVRange = adjustedMemberVRange vr peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange @@ -409,6 +413,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ { groupId, localDisplayName, groupProfile, + businessChat = Nothing, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, @@ -423,6 +428,11 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ groupMemberId ) +businessChatTuple :: Maybe BusinessChatInfo -> (Maybe MemberId, Maybe BusinessChatType) +businessChatTuple = \case + Just BusinessChatInfo {memberId, chatType} -> (Just memberId, Just chatType) + Nothing -> (Nothing, Nothing) + adjustedMemberVRange :: VersionRangeChat -> VersionRangeChat -> VersionRangeChat adjustedMemberVRange chatVR vr@(VersionRange minV maxV) = let maxV' = min maxV (maxVersion chatVR) @@ -497,13 +507,19 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe ) pure $ Right incognitoLdn +deleteContactCardKeepConn :: DB.Connection -> Int64 -> Contact -> IO () +deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile {profileId}} = do + DB.execute db "UPDATE connections SET contact_id = NULL WHERE connection_id = ?" (Only connId) + DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) + DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) + createGroupInvitedViaLink :: DB.Connection -> VersionRangeChat -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) createGroupInvitedViaLink db vr user@User {userId, userContactId} Connection {connId, customUserProfileId} - GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile} = do + GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, businessChat} = do currentTs <- liftIO getCurrentTime groupId <- insertGroup_ currentTs hostMemberId <- insertHost_ currentTs groupId @@ -527,10 +543,10 @@ createGroupInvitedViaLink [sql| INSERT INTO groups (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at) - VALUES (?,?,?,?,?,?,?,?,?) + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_member_id, business_chat) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) + ((profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) :. businessChatTuple businessChat) insertedRowId db insertHost_ currentTs groupId = do let fromMemberProfile = profileFromName fromMemberName @@ -637,7 +653,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_member_id, g.business_chat, g.ui_themes, g.custom_data, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g @@ -879,9 +895,7 @@ createAcceptedMember User {userId, userContactId} GroupInfo {groupId, membership} UserContactRequest {cReqChatVRange, localDisplayName, profileId} - memberRole = do - liftIO $ - DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + memberRole = createWithRandomId gVar $ \memId -> do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt @@ -917,6 +931,46 @@ createAcceptedMemberConnection Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV cReqChatVRange Nothing (Just userContactLinkId) Nothing 0 createdAt subMode PQSupportOff setCommandConnId db user cmdId connId +createBusinessRequestGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> UserContactRequest -> GroupPreferences -> ExceptT StoreError IO (GroupInfo, GroupMember) +createBusinessRequestGroup + db + vr + gVar + user@User {userId} + ucr@UserContactRequest {profile} + groupPreferences = do + currentTs <- liftIO getCurrentTime + groupInfo <- insertGroup_ currentTs + (groupMemberId, memberId) <- createAcceptedMember db gVar user groupInfo ucr GRMember + liftIO $ setBusinessMemberId groupInfo memberId + acceptedMember <- getGroupMemberById db vr user groupMemberId + pure (groupInfo, acceptedMember) + where + insertGroup_ currentTs = ExceptT $ do + let Profile {displayName, fullName, image} = profile + withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do + groupId <- liftIO $ do + DB.execute + db + "INSERT INTO group_profiles (display_name, full_name, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + (displayName, fullName, image, userId, groupPreferences, currentTs, currentTs) + profileId <- insertedRowId db + DB.execute + db + [sql| + INSERT INTO groups + (group_profile_id, local_display_name, user_id, enable_ntfs, + created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat) + VALUES (?,?,?,?,?,?,?,?,?) + |] + (profileId, localDisplayName, userId, True, currentTs, currentTs, currentTs, currentTs, BCCustomer) + insertedRowId db + memberId <- liftIO $ encodedRandomBytes gVar 12 + void $ createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs vr + getGroupInfo db vr user groupId + setBusinessMemberId GroupInfo {groupId} businessMemberId = do + DB.execute db "UPDATE groups SET business_member_id = ? WHERE group_id = ?" (businessMemberId, groupId) + getContactViaMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> ExceptT StoreError IO Contact getContactViaMember db vr user@User {userId} GroupMember {groupMemberId} = do contactId <- @@ -1315,7 +1369,7 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_member_id, g.business_chat, g.ui_themes, g.custom_data, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -1411,7 +1465,7 @@ getGroupInfo db vr User {userId, userContactId} groupId = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_member_id, g.business_chat, g.ui_themes, g.custom_data, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 9a91c7f970..6654dec034 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -117,6 +117,7 @@ import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id import Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id import Simplex.Chat.Migrations.M20241027_server_operators import Simplex.Chat.Migrations.M20241125_indexes +import Simplex.Chat.Migrations.M20241128_business_chats import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -233,7 +234,8 @@ schemaMigrations = ("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id), ("20241023_chat_item_autoincrement_id", m20241023_chat_item_autoincrement_id, Just down_m20241023_chat_item_autoincrement_id), ("20241027_server_operators", m20241027_server_operators, Just down_m20241027_server_operators), - ("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes) + ("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes), + ("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index ec657fd6f7..e88cf39feb 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -445,7 +445,8 @@ data UserContactLink = UserContactLink deriving (Show) data AutoAccept = AutoAccept - { acceptIncognito :: IncognitoEnabled, + { businessAddress :: Bool, -- possibly, it can be wrapped together with acceptIncognito, or AutoAccept made sum type + acceptIncognito :: IncognitoEnabled, autoReply :: Maybe MsgContent } deriving (Show) @@ -454,10 +455,10 @@ $(J.deriveJSON defaultJSON ''AutoAccept) $(J.deriveJSON defaultJSON ''UserContactLink) -toUserContactLink :: (ConnReqContact, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink -toUserContactLink (connReq, autoAccept, acceptIncognito, autoReply) = +toUserContactLink :: (ConnReqContact, Bool, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink +toUserContactLink (connReq, autoAccept, businessAddress, acceptIncognito, autoReply) = UserContactLink connReq $ - if autoAccept then Just AutoAccept {acceptIncognito, autoReply} else Nothing + if autoAccept then Just AutoAccept {businessAddress, acceptIncognito, autoReply} else Nothing getUserAddress :: DB.Connection -> User -> ExceptT StoreError IO UserContactLink getUserAddress db User {userId} = @@ -465,7 +466,7 @@ getUserAddress db User {userId} = DB.query db [sql| - SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content + SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL |] @@ -477,7 +478,7 @@ getUserContactLinkById db userId userContactLinkId = DB.query db [sql| - SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role + SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? @@ -490,7 +491,7 @@ getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = DB.query db [sql| - SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content + SELECT conn_req_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links WHERE user_id = ? AND conn_req_contact IN (?,?) |] @@ -522,13 +523,13 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do db [sql| UPDATE user_contact_links - SET auto_accept = ?, auto_accept_incognito = ?, auto_reply_msg_content = ? + SET auto_accept = ?, business_address = ?, auto_accept_incognito = ?, auto_reply_msg_content = ? WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL |] (ucl :. Only userId) ucl = case autoAccept of - Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply) - _ -> (False, False, Nothing) + Just AutoAccept {businessAddress, acceptIncognito, autoReply} -> (True, businessAddress, acceptIncognito, autoReply) + _ -> (False, False, False, Nothing) getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p] getProtocolServers db p User {userId} = @@ -589,7 +590,7 @@ getServerOperators db = do let conditionsAction = usageConditionsAction ops currentConditions now pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction} -getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) +getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) getUserServers db user = (,,) <$> (map Just . serverOperators <$> getServerOperators db) @@ -620,7 +621,8 @@ getUpdateServerOperators db presetOps newUser = do mapM_ insertConditions condsToAdd latestAcceptedConds_ <- getLatestAcceptedConditions db ops <- updatedServerOperators presetOps <$> getServerOperators_ db - forM ops $ traverse $ mapM $ \(ASO _ op) -> -- traverse for tuple, mapM for Maybe + forM ops $ traverse $ mapM $ \(ASO _ op) -> + -- traverse for tuple, mapM for Maybe case operatorId op of DBNewEntity -> do op' <- insertOperator op @@ -765,8 +767,9 @@ acceptConditions db condId opIds acceptedAt = do liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit ts where getServerOperator_ opId = - ExceptT $ firstRow toServerOperator (SEOperatorNotFound opId) $ - DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId) + ExceptT $ + firstRow toServerOperator (SEOperatorNotFound opId) $ + DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId) acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> Maybe UTCTime -> IO () acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 36bf9edb52..cc98b4101d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -52,7 +52,7 @@ import Simplex.Messaging.Agent.Protocol (ACorrId, AEventTag (..), AEvtTag (..), import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal @@ -371,6 +371,7 @@ data GroupInfo = GroupInfo { groupId :: GroupId, localDisplayName :: GroupName, groupProfile :: GroupProfile, + businessChat :: Maybe BusinessChatInfo, fullGroupPreferences :: FullGroupPreferences, membership :: GroupMember, hostConnCustomUserProfileId :: Maybe ProfileId, @@ -384,6 +385,24 @@ data GroupInfo = GroupInfo } deriving (Eq, Show) +data BusinessChatType + = BCBusiness -- used on the customer side + | BCCustomer -- used on the business side + deriving (Eq, Show) + +instance TextEncoding BusinessChatType where + textEncode = \case + BCBusiness -> "business" + BCCustomer -> "customer" + textDecode = \case + "business" -> Just BCBusiness + "customer" -> Just BCCustomer + _ -> Nothing + +instance FromField BusinessChatType where fromField = fromTextField_ textDecode + +instance ToField BusinessChatType where toField = toField . textEncode + groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g @@ -598,6 +617,7 @@ data GroupInvitation = GroupInvitation invitedMember :: MemberIdRole, connRequest :: ConnReqInvitation, groupProfile :: GroupProfile, + businessChat :: Maybe BusinessChatInfo, groupLinkId :: Maybe GroupLinkId, groupSize :: Maybe Int } @@ -608,6 +628,7 @@ data GroupLinkInvitation = GroupLinkInvitation fromMemberName :: ContactName, invitedMember :: MemberIdRole, groupProfile :: GroupProfile, + businessChat :: Maybe BusinessChatInfo, groupSize :: Maybe Int } deriving (Eq, Show) @@ -632,6 +653,12 @@ data MemberInfo = MemberInfo } deriving (Eq, Show) +data BusinessChatInfo = BusinessChatInfo + { memberId :: MemberId, + chatType :: BusinessChatType + } + deriving (Eq, Show) + memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = MemberInfo @@ -1696,6 +1723,10 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter) $(JQ.deriveJSON defaultJSON ''ChatSettings) +$(JQ.deriveJSON (enumJSON $ dropPrefix "BC") ''BusinessChatType) + +$(JQ.deriveJSON defaultJSON ''BusinessChatInfo) + $(JQ.deriveJSON defaultJSON ''GroupInfo) $(JQ.deriveJSON defaultJSON ''Group) @@ -1706,18 +1737,18 @@ instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP instance ToField MsgFilter where toField = toField . msgFilterInt -$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "CRData") ''CReqClientData) +$(JQ.deriveJSON defaultJSON ''CReqClientData) $(JQ.deriveJSON defaultJSON ''MemberIdRole) +$(JQ.deriveJSON defaultJSON ''MemberInfo) + $(JQ.deriveJSON defaultJSON ''GroupInvitation) $(JQ.deriveJSON defaultJSON ''GroupLinkInvitation) $(JQ.deriveJSON defaultJSON ''IntroInvitation) -$(JQ.deriveJSON defaultJSON ''MemberInfo) - $(JQ.deriveJSON defaultJSON ''MemberRestrictions) $(JQ.deriveJSON defaultJSON ''GroupMemberRef) diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index bccfd4bdce..8465caeee0 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -390,6 +390,33 @@ defaultGroupPrefs = emptyGroupPrefs :: GroupPreferences emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing +businessGroupPrefs :: Preferences -> GroupPreferences +businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice} = + defaultBusinessGroupPrefs + { timedMessages = Just TimedMessagesGroupPreference {enable = maybe FEOff enableFeature timedMessages, ttl = maybe Nothing prefParam timedMessages}, + fullDelete = Just FullDeleteGroupPreference {enable = maybe FEOff enableFeature fullDelete}, + reactions = Just ReactionsGroupPreference {enable = maybe FEOn enableFeature reactions}, + voice = Just VoiceGroupPreference {enable = maybe FEOff enableFeature voice, role = Nothing} + } + where + enableFeature :: FeatureI f => FeaturePreference f -> GroupFeatureEnabled + enableFeature p = case getField @"allow" p of + FANo -> FEOff + _ -> FEOn + +defaultBusinessGroupPrefs :: GroupPreferences +defaultBusinessGroupPrefs = + GroupPreferences + { timedMessages = Just $ TimedMessagesGroupPreference FEOff Nothing, + directMessages = Just $ DirectMessagesGroupPreference FEOff Nothing, + fullDelete = Just $ FullDeleteGroupPreference FEOff, + reactions = Just $ ReactionsGroupPreference FEOn, + voice = Just $ VoiceGroupPreference FEOff Nothing, + files = Just $ FilesGroupPreference FEOn Nothing, + simplexLinks = Just $ SimplexLinksGroupPreference FEOn Nothing, + history = Just $ HistoryGroupPreference FEOn + } + data TimedMessagesPreference = TimedMessagesPreference { allow :: FeatureAllowed, ttl :: Maybe Int diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 093d750a42..4458f8ee7b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -204,12 +204,14 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRAcceptingContactRequest u c -> ttyUser u $ viewAcceptingContactRequest c + CRAcceptingBusinessRequest u g -> ttyUser u $ viewAcceptingBusinessRequest g CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] CRContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] CRUserContactLinkCreated u cReq -> ttyUser u $ connReqContact_ "Your new chat address is created!" cReq CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] + CRBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] @@ -979,9 +981,14 @@ simplexChatContact (CRContactUri crData) = CRContactUri crData {crScheme = simpl autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString] autoAcceptStatus_ = \case - Just AutoAccept {acceptIncognito, autoReply} -> - ("auto_accept on" <> if acceptIncognito then ", incognito" else "") + Just AutoAccept {businessAddress, acceptIncognito, autoReply} -> + ("auto_accept on" <> aaInfo) : maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply + where + aaInfo + | businessAddress = ", business" + | acceptIncognito = ", incognito" + | otherwise = "" _ -> ["auto_accept off"] groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> [StyledString] @@ -1017,6 +1024,9 @@ viewAcceptingContactRequest ct | contactReady ct = [ttyFullContact ct <> ": accepting contact request, you can send messages to contact"] | otherwise = [ttyFullContact ct <> ": accepting contact request..."] +viewAcceptingBusinessRequest :: GroupInfo -> [StyledString] +viewAcceptingBusinessRequest g = [ttyFullGroup g <> ": accepting business address request..."] + viewReceivedContactRequest :: ContactName -> Profile -> [StyledString] viewReceivedContactRequest c Profile {fullName} = [ ttyFullName c fullName <> " wants to connect to you!", diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 3ff8808541..b2db679f29 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -47,6 +47,8 @@ chatProfileTests = do it "delete connection requests when contact link deleted" testDeleteConnectionRequests it "auto-reply message" testAutoReplyMessage it "auto-reply message in incognito" testAutoReplyMessageInIncognito + describe "business address" $ do + it "create and connect via business address" testBusinessAddress describe "contact address connection plan" $ do it "contact address ok to connect; known contact" testPlanAddressOkKnown it "own contact address" testPlanAddressOwn @@ -677,6 +679,49 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ alice <## "use /i bob to print out this incognito profile again" ] +testBusinessAddress :: HasCallStack => FilePath -> IO () +testBusinessAddress = testChat3 businessProfile aliceProfile {fullName = "Alice @ Biz"} bobProfile $ + \biz alice bob -> do + biz ##> "/ad" + cLink <- getContactLink biz True + biz ##> "/auto_accept on business" + biz <## "auto_accept on, business" + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + biz <## "#bob_1 (Bob): accepting business address request..." + biz <## "#bob_1: bob joined the group" + bob <## "#biz: joining the group..." + bob <## "#biz: you joined the group" + biz #> "#bob_1 hi" + bob <# "#biz biz_1> hi" + bob #> "#biz hello" + biz <# "#bob_1 bob> hello" + connectUsers biz alice + biz <##> alice + biz ##> "/a #bob_1 alice" + biz <## "invitation to join the group #bob_1 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_1: 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_1 alice> hey") + bob #> "#biz hey there" + concurrently_ + (alice <# "#bob bob_1> hey there") + (biz <# "#bob_1 bob> hey there") + testPlanAddressOkKnown :: HasCallStack => FilePath -> IO () testPlanAddressOkKnown = testChat2 aliceProfile bobProfile $ @@ -2380,7 +2425,7 @@ testSetUITheme = a <## "you've shared main profile with this contact" a <## "connection not verified, use /code command to see security code" a <## "quantum resistant end-to-end encryption" - a <## "peer chat protocol version range: (Version 1, Version 9)" + a <## "peer chat protocol version range: (Version 1, Version 10)" groupInfo a = do a <## "group ID: 1" a <## "current members: 1" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 8c022d5bfd..6459522134 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -64,6 +64,9 @@ cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Not danProfile :: Profile danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} +businessProfile :: Profile +businessProfile = Profile {displayName = "biz", fullName = "Biz Inc", image = Nothing, contactLink = Nothing, preferences = defaultPrefs} + it :: HasCallStack => String -> (FilePath -> Expectation) -> SpecWith (Arg (FilePath -> Expectation)) it name test = Hspec.it name $ \tmp -> timeout t (test tmp) >>= maybe (error "test timed out") pure diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index f64efe108f..2eb946d731 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-10\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -232,10 +232,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ==# XContact testProfile Nothing it "x.grp.inv" $ "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" - #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing, groupSize = Nothing} + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, businessChat = Nothing, groupLinkId = Nothing, groupSize = Nothing} it "x.grp.inv with group link id" $ "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" - #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} + #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, businessChat = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") @@ -243,13 +243,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-9\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-10\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-9\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-10\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -264,7 +264,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-9\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-10\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"