From 2eb25d124fab3a6df078c30dde2a121b594a2cc9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:17:42 +0000 Subject: [PATCH] core, ui: better error on failed channel creation (#6825) --- apps/ios/Shared/Model/AppAPITypes.swift | 8 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 32 ++++++- .../Shared/Views/NewChat/AddChannelView.swift | 35 +++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 36 ++++++- .../common/views/newchat/AddChannelView.kt | 40 +++++--- .../commonMain/resources/MR/base/strings.xml | 4 + bots/api/COMMANDS.md | 5 + bots/api/TYPES.md | 10 ++ bots/src/API/Docs/Commands.hs | 2 +- bots/src/API/Docs/Responses.hs | 1 + bots/src/API/Docs/Types.hs | 2 + .../types/typescript/src/commands.ts | 2 +- .../types/typescript/src/responses.ts | 8 ++ .../types/typescript/src/types.ts | 5 + src/Simplex/Chat/Controller.hs | 9 ++ src/Simplex/Chat/Library/Commands.hs | 95 +++++++++++-------- .../SQLite/Migrations/chat_query_plans.txt | 4 + src/Simplex/Chat/View.hs | 9 ++ tests/ChatTests/Groups.hs | 28 ++++++ 19 files changed, 268 insertions(+), 67 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 6c7544e724..c93cc233f5 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -942,6 +942,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { // group responses case groupCreated(user: UserRef, groupInfo: GroupInfo) case publicGroupCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay]) + case publicGroupCreationFailed(user: UserRef, addRelayResults: [AddRelayResult]) case groupRelays(user: UserRef, groupInfo: GroupInfo, groupRelays: [GroupRelay]) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) @@ -994,6 +995,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { switch self { case .groupCreated: "groupCreated" case .publicGroupCreated: "publicGroupCreated" + case .publicGroupCreationFailed: "publicGroupCreationFailed" case .groupRelays: "groupRelays" case .sentGroupInvitation: "sentGroupInvitation" case .userAcceptedGroupSent: "userAcceptedGroupSent" @@ -1042,6 +1044,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { switch self { case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .publicGroupCreated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)") + case let .publicGroupCreationFailed(u, addRelayResults): return withUser(u, "addRelayResults: \(addRelayResults)") case let .groupRelays(u, groupInfo, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupRelays: \(groupRelays)") case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") @@ -1981,6 +1984,11 @@ struct RelayConnectionResult: Decodable { var relayError: ChatError? } +struct AddRelayResult: Decodable { + var relay: UserChatRelay + var relayError: ChatError? +} + enum ProtocolTestStep: String, Decodable, Equatable { case connect case disconnect diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 94707c6602..20653ab9db 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1117,6 +1117,27 @@ private func apiConnectResponseAlert(_ r: APIResult) -> Alert { } } +func connErrorText(_ e: ChatError) -> String { + switch e { + case .error(.invalidConnReq): + NSLocalizedString("Invalid connection link", comment: "conn error description") + case .error(.unsupportedConnReq): + NSLocalizedString("Unsupported connection link", comment: "conn error description") + case .errorAgent(.SMP(_, .AUTH)): + NSLocalizedString("Connection error (AUTH)", comment: "conn error description") + case let .errorAgent(.SMP(_, .BLOCKED(info))): + NSLocalizedString("Connection blocked: \(info.reason.text)", comment: "conn error description") + case .errorAgent(.SMP(_, .QUOTA)): + NSLocalizedString("The connection reached the limit of undelivered messages", comment: "conn error description") + default: + if getNetworkErrorAlert(e) != nil { + NSLocalizedString("Network error", comment: "conn error description") + } else { + "\(NSLocalizedString("Error", comment: "conn error description")): \(responseError(e))" + } + } +} + func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { mkAlert( title: "Contact already exists", @@ -1847,12 +1868,19 @@ func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInf throw r.unexpected } -func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay])? { +enum PublicGroupCreationResult { + case created(GroupInfo, GroupLink, [GroupRelay]) + case creationFailed([AddRelayResult]) +} + +func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> PublicGroupCreationResult? { let userId = try currentUserId("apiNewPublicGroup") let r: APIResult? = await chatApiSendCmdWithRetry(.apiNewPublicGroup(userId: userId, incognito: incognito, relayIds: relayIds, groupProfile: groupProfile)) switch r { case let .result(.publicGroupCreated(_, groupInfo, groupLink, groupRelays)): - return (groupInfo, groupLink, groupRelays) + return .created(groupInfo, groupLink, groupRelays) + case let .result(.publicGroupCreationFailed(_, addRelayResults)): + return .creationFailed(addRelayResults) default: if let r { throw r.unexpected } else { return nil } } } diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 5a07084fa6..3951d8261e 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -174,20 +174,32 @@ struct AddChannelView: View { } return } - guard let (gInfo, gLink, gRelays) = try await apiNewPublicGroup( + guard let result = try await apiNewPublicGroup( incognito: false, relayIds: relayIds, groupProfile: profile ) else { await MainActor.run { creationInProgress = false } return } - await MainActor.run { - m.updateGroup(gInfo) - m.creatingChannelId = gInfo.id - groupInfo = gInfo - groupLink = gLink - groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) } - channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays) - creationInProgress = false + switch result { + case let .created(gInfo, gLink, gRelays): + await MainActor.run { + m.updateGroup(gInfo) + m.creatingChannelId = gInfo.id + groupInfo = gInfo + groupLink = gLink + groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) } + channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays) + creationInProgress = false + } + case let .creationFailed(relayResults): + await MainActor.run { + creationInProgress = false + showAlert( + NSLocalizedString("Error creating channel", comment: "alert title"), + message: NSLocalizedString("Relay results:", comment: "alert message") + "\n" + + relayResults.map { "\(chatRelayDisplayName($0.relay)): \($0.relayError.map { connErrorText($0) } ?? "ok")" }.joined(separator: "\n") + ) + } } } catch { await MainActor.run { @@ -429,6 +441,11 @@ func relayDisplayName(_ relay: GroupRelay) -> String { return "relay \(relay.groupRelayId)" } +private func chatRelayDisplayName(_ relay: UserChatRelay) -> String { + if !relay.displayName.isEmpty { return relay.displayName } + return relay.address +} + func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View { let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index d4f1fa203d..a1ff579c27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1564,6 +1564,23 @@ object ChatController { } } + fun connErrorText(e: ChatError): String = when { + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.InvalidConnReq -> + generalGetString(MR.strings.invalid_connection_link) + e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.UnsupportedConnReq -> + generalGetString(MR.strings.unsupported_connection_link) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH -> + generalGetString(MR.strings.connection_error_auth) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.BLOCKED -> + "${generalGetString(MR.strings.connection_error_blocked)}: ${e.agentError.smpErr.blockInfo.reason.text}" + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.QUOTA -> + generalGetString(MR.strings.connection_reached_limit_of_undelivered_messages) + e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.BROKER -> + generalGetString(MR.strings.network_error) + else -> + "${generalGetString(MR.strings.error_prefix)}: ${e.string}" + } + suspend fun apiPrepareContact(rh: Long?, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData): Chat? { val userId = try { currentUserId("apiPrepareContact") } catch (e: Exception) { return null } val r = sendCmd(rh, CC.APIPrepareContact(userId, connLink, contactShortLinkData)) @@ -2118,10 +2135,16 @@ object ChatController { return null } - suspend fun apiNewPublicGroup(rh: Long?, incognito: Boolean, relayIds: List, groupProfile: GroupProfile): Triple>? { + sealed class PublicGroupCreationResult { + data class Created(val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): PublicGroupCreationResult() + data class CreationFailed(val addRelayResults: List): PublicGroupCreationResult() + } + + suspend fun apiNewPublicGroup(rh: Long?, incognito: Boolean, relayIds: List, groupProfile: GroupProfile): PublicGroupCreationResult? { val userId = kotlin.runCatching { currentUserId("apiNewPublicGroup") }.getOrElse { return null } val r = sendCmdWithRetry(rh, CC.ApiNewPublicGroup(userId, incognito, relayIds, groupProfile)) - if (r is API.Result && r.res is CR.PublicGroupCreated) return Triple(r.res.groupInfo, r.res.groupLink, r.res.groupRelays) + if (r is API.Result && r.res is CR.PublicGroupCreated) return PublicGroupCreationResult.Created(r.res.groupInfo, r.res.groupLink, r.res.groupRelays) + if (r is API.Result && r.res is CR.PublicGroupCreationFailed) return PublicGroupCreationResult.CreationFailed(r.res.addRelayResults) if (r != null) throw Exception("${r.responseType}: ${r.details}") return null } @@ -4569,6 +4592,12 @@ data class RelayConnectionResult( val relayError: ChatError? = null ) +@Serializable +data class AddRelayResult( + val relay: UserChatRelay, + val relayError: ChatError? = null +) + @Serializable data class GroupShortLinkInfo( val direct: Boolean, @@ -6345,6 +6374,7 @@ sealed class CR { // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("publicGroupCreated") class PublicGroupCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): CR() + @Serializable @SerialName("publicGroupCreationFailed") class PublicGroupCreationFailed(val user: UserRef, val addRelayResults: List): CR() @Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @@ -6533,6 +6563,7 @@ sealed class CR { is ForwardPlan -> "forwardPlan" is GroupCreated -> "groupCreated" is PublicGroupCreated -> "publicGroupCreated" + is PublicGroupCreationFailed -> "publicGroupCreationFailed" is GroupRelays -> "groupRelays" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" @@ -6714,6 +6745,7 @@ sealed class CR { is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}") is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is PublicGroupCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays") + is PublicGroupCreationFailed -> withUser(user, "addRelayResults: $addRelayResults") is GroupRelays -> withUser(user, "groupInfo: $groupInfo\ngroupRelays: $groupRelays") is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt index a0e1359e90..f0bba5c4ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -130,19 +130,31 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit relayIds = relayIds, groupProfile = profile ) - if (result != null) { - val (gI, gL, gR) = result - withContext(Dispatchers.Main) { - chatModel.chatsContext.updateGroup(rhId = null, gI) - chatModel.creatingChannelId.value = gI.id - groupInfo.value = gI - groupLink.value = gL - groupRelays.value = gR.sortedBy { relayDisplayName(it) } - ChannelRelaysModel.set(gI.groupId, gR) - creationInProgress.value = false + when (result) { + is ChatController.PublicGroupCreationResult.Created -> { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId = null, result.groupInfo) + chatModel.creatingChannelId.value = result.groupInfo.id + groupInfo.value = result.groupInfo + groupLink.value = result.groupLink + groupRelays.value = result.groupRelays.sortedBy { relayDisplayName(it) } + ChannelRelaysModel.set(result.groupInfo.groupId, result.groupRelays) + creationInProgress.value = false + } + } + is ChatController.PublicGroupCreationResult.CreationFailed -> { + withContext(Dispatchers.Main) { + creationInProgress.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_creating_channel), + text = generalGetString(MR.strings.relay_results) + "\n" + + result.addRelayResults.joinToString("\n") { "${chatRelayDisplayName(it.relay)}: ${it.relayError?.let { e -> ChatController.connErrorText(e) } ?: "ok"}" } + ) + } + } + null -> { + withContext(Dispatchers.Main) { creationInProgress.value = false } } - } else { - withContext(Dispatchers.Main) { creationInProgress.value = false } } } catch (e: Exception) { withContext(Dispatchers.Main) { @@ -545,6 +557,10 @@ fun relayDisplayName(relay: GroupRelay): String { return "relay ${relay.groupRelayId}" } +private fun chatRelayDisplayName(relay: UserChatRelay): String { + if (relay.displayName.isNotEmpty()) return relay.displayName + return relay.address +} @Composable fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false, memberStatus: GroupMemberStatus? = null) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 0630dffe22..abb1d55942 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2943,6 +2943,10 @@ Channel name Creating channel Error creating channel + Relay results: + The connection reached the limit of undelivered messages + Network error + Error Cancel creating channel? Cancel Enable at least one chat relay to create a channel. diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 07bb692e0f..ab3ec3d241 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -983,6 +983,11 @@ PublicGroupCreated: Public group created. - groupLink: [GroupLink](./TYPES.md#grouplink) - groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] +PublicGroupCreationFailed: Public group creation failed. +- type: "publicGroupCreationFailed" +- user: [User](./TYPES.md#user) +- addRelayResults: [[AddRelayResult](./TYPES.md#addrelayresult)] + ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" - chatError: [ChatError](./TYPES.md#chaterror) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index ec62577a72..23fc79b634 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -5,6 +5,7 @@ This file is generated automatically. - [ACIReaction](#acireaction) - [AChat](#achat) - [AChatItem](#achatitem) +- [AddRelayResult](#addrelayresult) - [AddressSettings](#addresssettings) - [AgentCryptoError](#agentcryptoerror) - [AgentErrorType](#agenterrortype) @@ -220,6 +221,15 @@ This file is generated automatically. - chatItem: [ChatItem](#chatitem) +--- + +## AddRelayResult + +**Record type**: +- relay: [UserChatRelay](#userchatrelay) +- relayError: [ChatError](#chaterror)? + + --- ## AddressSettings diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index de91c751d2..ae8ce7c05b 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -117,7 +117,7 @@ chatCommandsDocsData = ("APILeaveGroup", [], "Leave group.", ["CRLeftMemberUser", "CRChatCmdError"], [], Just UNBackground, "/_leave #" <> Param "groupId"), ("APIListMembers", [], "Get group members.", ["CRGroupMembers", "CRChatCmdError"], [], Nothing, "/_members #" <> Param "groupId"), ("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"), - ("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"), + ("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRPublicGroupCreationFailed", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"), ("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"), ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") ] diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index eb67c5670b..c3ab85ece6 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -69,6 +69,7 @@ chatResponsesDocsData = ("CRGroupLinkDeleted", ""), ("CRGroupCreated", ""), ("CRPublicGroupCreated", ""), + ("CRPublicGroupCreationFailed", ""), ("CRGroupRelays", ""), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 18b7d5e43b..e2f67c88c6 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -202,6 +202,7 @@ chatTypesDocsData = (sti @(ContactUserPref SimplePreference), STUnion, "CUP", [], "", ""), (sti @(ContactUserPreference SimplePreference), STRecord, "", [], "", ""), (sti @(CreatedConnLink 'CMContact), STRecord, "", [], Param "connFullLink" <> Optional "" (" " <> Param "$0") "connShortLink", ""), + (sti @AddRelayResult, STRecord, "", [], "", ""), (sti @AddressSettings, STRecord, "", [], "", ""), (sti @AgentCryptoError, STUnion, "", ["RATCHET_EARLIER", "RATCHET_SKIPPED"], "", ""), -- TODO add fields to types (sti @AgentErrorType, STUnion, "", [], "", ""), @@ -405,6 +406,7 @@ deriving instance Generic (CIReaction c d) deriving instance Generic (ContactUserPref p) deriving instance Generic (ContactUserPreference p) deriving instance Generic (CreatedConnLink m) +deriving instance Generic AddRelayResult deriving instance Generic AddressSettings deriving instance Generic AgentCryptoError deriving instance Generic AgentErrorType diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index 2f2b114bfe..36692739dd 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -351,7 +351,7 @@ export interface APINewPublicGroup { } export namespace APINewPublicGroup { - export type Response = CR.PublicGroupCreated | CR.ChatCmdError + export type Response = CR.PublicGroupCreated | CR.PublicGroupCreationFailed | CR.ChatCmdError export function cmdString(self: APINewPublicGroup): string { return '/_public group ' + self.userId + (self.incognito ? ' incognito=on' : '') + ' ' + self.relayIds.join(',') + ' ' + JSON.stringify(self.groupProfile) diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 8d4f68c000..02aa29444b 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -28,6 +28,7 @@ export type ChatResponse = | CR.GroupLinkDeleted | CR.GroupCreated | CR.PublicGroupCreated + | CR.PublicGroupCreationFailed | CR.GroupRelays | CR.GroupMembers | CR.GroupUpdated @@ -81,6 +82,7 @@ export namespace CR { | "groupLinkDeleted" | "groupCreated" | "publicGroupCreated" + | "publicGroupCreationFailed" | "groupRelays" | "groupMembers" | "groupUpdated" @@ -258,6 +260,12 @@ export namespace CR { groupRelays: T.GroupRelay[] } + export interface PublicGroupCreationFailed extends Interface { + type: "publicGroupCreationFailed" + user: T.User + addRelayResults: T.AddRelayResult[] + } + export interface GroupRelays extends Interface { type: "groupRelays" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 9b0934d67b..6f4f0b6525 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -17,6 +17,11 @@ export interface AChatItem { chatItem: ChatItem } +export interface AddRelayResult { + relay: UserChatRelay + relayError?: ChatError +} + export interface AddressSettings { businessAddress: boolean autoAccept?: AutoAccept diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3b128a3183..e989e520a5 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -651,6 +651,12 @@ data RelayConnectionResult = RelayConnectionResult } deriving (Show) +data AddRelayResult = AddRelayResult + { relay :: UserChatRelay, + relayError :: Maybe ChatError + } + deriving (Show) + data RelayTestStep = RTSGetLink | RTSDecodeLink @@ -721,6 +727,7 @@ data ChatResponse | CRWelcome {user :: User} | CRGroupCreated {user :: User, groupInfo :: GroupInfo} | CRPublicGroupCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} + | CRPublicGroupCreationFailed {user :: User, addRelayResults :: [AddRelayResult]} | CRGroupRelays {user :: User, groupInfo :: GroupInfo, groupRelays :: [GroupRelay]} | CRGroupMembers {user :: User, group :: Group} | CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} @@ -1713,6 +1720,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "TE") ''TerminalEvent) $(JQ.deriveJSON defaultJSON ''RelayConnectionResult) +$(JQ.deriveJSON defaultJSON ''AddRelayResult) + $(JQ.deriveJSON (enumJSON $ dropPrefix "RTS") ''RelayTestStep) $(JQ.deriveJSON defaultJSON ''RelayTestFailure) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 95c5014d41..042280e062 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2479,13 +2479,29 @@ processChatCommand vr nm = \case APINewPublicGroup userId incognito relayIds groupProfile -> withUserId userId $ \user -> do (gProfile', memberId, groupKeys, setupLink) <- prepareGroupLink user gInfo <- newGroup user incognito gProfile' True memberId (Just groupKeys) (Just 1) - (gLink, groupRelays) <- setupLink gInfo `catchAllErrors` \e -> do + (gLink, results) <- setupLink gInfo `catchAllErrors` \e -> do deleteInProgressGroup user gInfo throwError e - createNewGroupItems user gInfo - pure $ CRPublicGroupCreated user gInfo gLink groupRelays + case partitionEithers (map snd results) of + ([], groupRelays) -> do + createNewGroupItems user gInfo + pure $ CRPublicGroupCreated user gInfo gLink groupRelays + (errors@(e : _), _) -> do + deleteInProgressGroup user gInfo + -- If all errors are temporary (network, timeout, host), throw to allow retry + if all isTempErr errors + then throwError e + else do + let relayResults = map toRelayResult results + toRelayResult (r, Left e) = AddRelayResult r (Just e) + toRelayResult (r, Right _) = AddRelayResult r Nothing + pure $ CRPublicGroupCreationFailed user relayResults where - prepareGroupLink :: User -> CM (GroupProfile, MemberId, GroupKeys, GroupInfo -> CM (GroupLink, [GroupRelay])) + isTempErr :: ChatError -> Bool + isTempErr = \case + ChatErrorAgent {agentError = e} -> temporaryOrHostError e + _ -> False + prepareGroupLink :: User -> CM (GroupProfile, MemberId, GroupKeys, GroupInfo -> CM (GroupLink, [(UserChatRelay, Either ChatError GroupRelay)])) prepareGroupLink user = do gVar <- asks random groupLinkId <- GroupLinkId <$> drgRandomBytes 16 @@ -2514,8 +2530,8 @@ processChatCommand vr nm = \case subRole <- asks $ channelSubscriberRole . config gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId subRole subMode relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds) - groupRelays <- addRelays user gInfo sLnk relays - pure (gLink, groupRelays) + results <- addRelays user gInfo sLnk relays + pure (gLink, results) pure (groupProfile', memberId, groupKeys, setupLink) NewPublicGroup incognito relayIds gProfile -> withUser $ \User {userId} -> processChatCommand vr nm $ APINewPublicGroup userId incognito relayIds gProfile @@ -3862,44 +3878,43 @@ processChatCommand vr nm = \case toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) - addRelays :: User -> GroupInfo -> ShortLinkContact -> [UserChatRelay] -> CM [GroupRelay] + addRelays :: User -> GroupInfo -> ShortLinkContact -> [UserChatRelay] -> CM [(UserChatRelay, Either ChatError GroupRelay)] addRelays user gInfo@GroupInfo {membership} groupSLink relays = mapConcurrently addRelay relays where - addRelay :: UserChatRelay -> CM GroupRelay + addRelay :: UserChatRelay -> CM (UserChatRelay, Either ChatError GroupRelay) addRelay relay@UserChatRelay {address} = do - -- TODO [relays] owner: track and reuse relay profiles - -- TODO - single profile linked to relay configuration record (chat_relays) - -- TODO - update when fetching link data from relay address - (FixedLinkData {linkConnReq = cReq}, _cData) <- getShortLinkConnReq nm user address - lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case - Nothing -> throwChatError CEInvalidConnReq - Just (agentV, _) -> do - let chatV = agentToChatVersion agentV - gVar <- asks random - subMode <- chatReadVar subscriptionMode - connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff - (relayMember, conn, groupRelay) <- withFastStore $ \db -> do - relayMember <- createRelayForOwner db vr gVar user gInfo relay - groupRelay <- createGroupRelayRecord db gInfo relayMember relay - conn <- createRelayConnection db vr user (groupMemberId' relayMember) connId ConnPrepared chatV subMode - pure (relayMember, conn, groupRelay) - let GroupMember {memberRole = userRole, memberId = userMemberId} = membership - allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo - membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership - GroupMember {memberId = relayMemberId} = relayMember - relayInv = GroupRelayInvitation { - fromMember = MemberIdRole userMemberId userRole, - fromMemberProfile = membershipProfile, - relayMemberId, - groupLink = groupSLink - } - dm <- encodeConnInfo $ XGrpRelayInv relayInv - (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode - let newConnStatus = if sqSecured then ConnSndReady else ConnJoined - withFastStore' $ \db -> do - void $ updateConnectionStatusFromTo db conn ConnPrepared newConnStatus - updateRelayStatusFromTo db groupRelay RSNew RSInvited + r <- tryAllErrors $ do + (FixedLinkData {linkConnReq = cReq}, _cData) <- getShortLinkConnReq nm user address + lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + Nothing -> throwChatError CEInvalidConnReq + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + gVar <- asks random + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + (relayMember, conn, groupRelay) <- withFastStore $ \db -> do + relayMember <- createRelayForOwner db vr gVar user gInfo relay + groupRelay <- createGroupRelayRecord db gInfo relayMember relay + conn <- createRelayConnection db vr user (groupMemberId' relayMember) connId ConnPrepared chatV subMode + pure (relayMember, conn, groupRelay) + let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + GroupMember {memberId = relayMemberId} = relayMember + relayInv = GroupRelayInvitation { + fromMember = MemberIdRole userMemberId userRole, + fromMemberProfile = membershipProfile, + relayMemberId, + groupLink = groupSLink + } + dm <- encodeConnInfo $ XGrpRelayInv relayInv + (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode + let newConnStatus = if sqSecured then ConnSndReady else ConnJoined + withFastStore' $ \db -> do + void $ updateConnectionStatusFromTo db conn ConnPrepared newConnStatus + updateRelayStatusFromTo db groupRelay RSNew RSInvited + pure (relay, r) privateGetUser :: UserId -> CM User privateGetUser userId = tryAllErrors (withStore (`getUser` userId)) >>= \case 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 231b1a73bf..82ca68a663 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -7131,6 +7131,10 @@ Query: UPDATE groups SET relay_own_status = ?, updated_at = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET relay_request_err_reason = ?, updated_at = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET request_shared_msg_id = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ba5d006c25..ce0f55cf01 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -180,6 +180,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView + CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results CRGroupRelays u g relays -> ttyUser u $ viewGroupRelays g relays CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms @@ -1238,6 +1239,14 @@ viewGroupCreated g testView = where relaysInstruction = "wait for selected relay(s) to join, then you can invite members via group link" +viewPublicGroupCreationFailed :: [AddRelayResult] -> [StyledString] +viewPublicGroupCreationFailed results = + ["channel not created, results:"] + <> map showRelayResult results + where + showRelayResult (AddRelayResult UserChatRelay {chatRelayId = DBEntityId i} err_) = + " relay " <> sShow i <> ": " <> maybe "ok" (plain . tshow) err_ + viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] viewCannotResendInvitation g c = [ ttyContact c <> " is already invited to group " <> ttyGroup' g, diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 9cba9e6b74..1a1bc4b82f 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -265,6 +265,7 @@ chatGroupTests = do it "relay should leave channel" testChannelRelayLeave it "owner should update profile in channel (signed)" testChannelOwnerProfileUpdate it "subscriber should update profile in channel (signed)" testChannelSubscriberProfileUpdate + it "should report relay results when one relay deleted its address" testChannelCreateDeletedRelay describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete @@ -9539,6 +9540,33 @@ testChannelSubscriberProfileUpdate ps = dan `hasContactProfiles` ["alice", "bob", "kate", "dave"] eve `hasContactProfiles` ["alice", "bob", "kate", "dave", "eve"] +testChannelCreateDeletedRelay :: HasCallStack => TestParams -> IO () +testChannelCreateDeletedRelay ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + bob ##> "/ad" + (bobSLink, _) <- getContactLinks bob True + cath ##> "/ad" + (cathSLink, _) <- getContactLinks cath True + + alice ##> ("/relays name=bob " <> bobSLink <> " name=cath " <> cathSLink) + alice <## "ok" + + -- cath deletes her address - simulates relay becoming unavailable + cath ##> "/da" + cath <## "Your chat address is deleted - accepted contacts will remain connected." + cath <## "To create a new chat address use /ad" + + -- channel creation fails because one relay's address was deleted + alice ##> "/public group relays=1,2 #team" + alice <## "channel not created, results:" + alice <## " relay 1: ok" + alice <##. " relay 2: ChatErrorAgent" + -- deleteInProgressGroup deletes relay connection alice joined on bob; + -- bob's agent reports AUTH error when the queue is gone — drain it. + void $ getTermLine bob + testChannelMessageUpdate :: HasCallStack => TestParams -> IO () testChannelMessageUpdate ps = withNewTestChat ps "alice" aliceProfile $ \alice ->