diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index a9f19a4e9..d2cecb5da 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.4.0" - case .flipcash2: return "0.7.0" + case .flipcash2: return "0.8.0" } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index f140af3e7..90fe4cdde 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -55,9 +55,13 @@ extension FlipClient { /// Starts a new group chat and returns its metadata on success. `rules` gates who may read/join /// and who may send; `nil` leaves the chat unrestricted. - public func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?) async throws -> Conversation { + /// + /// `idempotencyKey` must be minted by the caller where the user's intent to create the chat + /// originates (not here) and reused for every retry of that same attempt — see + /// `ChatService.startChat`. A retry with the same key returns the original chat. + public func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, idempotencyKey: UUID) async throws -> Conversation { try await withCheckedThrowingContinuation { c in - chatService.startChat(owner: owner, title: title, pictureBlobID: pictureBlobID, rules: rules) { c.resume(with: $0) } + chatService.startChat(owner: owner, title: title, pictureBlobID: pictureBlobID, rules: rules, idempotencyKey: idempotencyKey) { c.resume(with: $0) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 48733a932..af8414162 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -128,7 +128,13 @@ final class ChatService: Sendable { /// restrictions. On `.titleModerated` the server also reports which category flagged the /// title; `ErrorStartChat.titleModerated` carries it through so callers can say why, not just /// that the title was rejected. - func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, completion: @Sendable @escaping (Result) -> Void) { + /// + /// `idempotencyKey` is required by the server: caller and key together identify the chat being + /// created, so a retry with the same key returns the original chat (result `.ok`) rather than + /// creating a duplicate, even if `title`/`pictureBlobID`/`rules` differ on the retry. Mint it once + /// where the user's intent to create the chat originates and reuse it for every retry of that same + /// attempt — never generate a fresh key per call, or retries lose their idempotency. + func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, idempotencyKey: UUID, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Chat_V1_StartChatRequest.with { $0.group = .with { $0.title = title @@ -139,6 +145,7 @@ final class ChatService: Sendable { $0.rules = rules.proto } } + $0.idempotencyKey = .with { $0.value = idempotencyKey.data } $0.auth = owner.authFor(message: $0) }