From 0d00b17a6783ee8037e371967b4f3d2af9ff7340 Mon Sep 17 00:00:00 2001 From: Pat Nakajima Date: Wed, 11 Oct 2023 12:41:15 -0700 Subject: [PATCH] Start on v2 allow state stuff --- Package.resolved | 4 +- Sources/XMTP/Contacts.swift | 69 +++++++++ Sources/XMTP/Conversation.swift | 39 +++-- Sources/XMTP/Conversations.swift | 2 + Tests/XMTPTests/ContactsTests.swift | 28 ++++ Tests/XMTPTests/ConversationTests.swift | 180 ++++++++++++++---------- 6 files changed, 229 insertions(+), 93 deletions(-) diff --git a/Package.resolved b/Package.resolved index e5bfa207..b1574ca2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -149,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/xmtp/xmtp-rust-swift", "state" : { - "revision" : "4a76e5401fa780c40610e2f0d248f695261d08dd", - "version" : "0.3.1-beta0" + "revision" : "d1aaac47fc7c57645a6fe9e06972b957b3efa33c", + "version" : "0.3.5-beta0" } } ], diff --git a/Sources/XMTP/Contacts.swift b/Sources/XMTP/Contacts.swift index 1e750628..1887e44b 100644 --- a/Sources/XMTP/Contacts.swift +++ b/Sources/XMTP/Contacts.swift @@ -7,6 +7,35 @@ import Foundation +public enum AllowState: String, Codable { + case allowed, blocked, unknown +} + +struct AllowListEntry: Codable, Hashable { + enum EntryType: String, Codable { + case address + } + + static func address(_ address: String, type: AllowState) -> AllowListEntry { + AllowListEntry(value: address, entryType: .address, permissionType: type) + } + + var value: String + var entryType: EntryType + var permissionType: AllowState +} + +struct AllowList { + var allowedAddresses: Set = [] + var blockedAddresses: Set = [] + + var entries: Set = [] + + func state(address: String) -> AllowState { + entries.first(where: { $0.entryType == .address && $0.value == address })?.permissionType ?? AllowState.unknown + } +} + /// Provides access to contact bundles. public actor Contacts { var client: Client @@ -17,10 +46,50 @@ public actor Contacts { // Whether or not we have sent invite/intro to this contact var hasIntroduced: [String: Bool] = [:] + var allowList = AllowList() + init(client: Client) { self.client = client } + public func isAllowed(_ address: String) -> Bool { + for entry in allowList.entries { + switch entry.entryType { + case .address: + if address == entry.value { + return entry.permissionType == .allowed + } + } + } + + return false + } + + public func isBlocked(_ address: String) -> Bool { + for entry in allowList.entries { + switch entry.entryType { + case .address: + if address == entry.value { + return entry.permissionType == .blocked + } + } + } + + return false + } + + public func allow(addresses: [String]) { + for address in addresses { + allowList.entries.insert(.address(address, type: .allowed)) + } + } + + public func block(addresses: [String]) { + for address in addresses { + allowList.entries.insert(.address(address, type: .blocked)) + } + } + func markIntroduced(_ peerAddress: String, _ isIntroduced: Bool) { hasIntroduced[peerAddress] = isIntroduced } diff --git a/Sources/XMTP/Conversation.swift b/Sources/XMTP/Conversation.swift index 5d922af7..2b0c0c2f 100644 --- a/Sources/XMTP/Conversation.swift +++ b/Sources/XMTP/Conversation.swift @@ -29,6 +29,19 @@ public enum Conversation: Sendable { case v1, v2 } + public func allowState() async -> AllowState { + let client: Client + + switch self { + case .v1(let conversationV1): + client = conversationV1.client + case .v2(let conversationV2): + client = conversationV2.client + } + + return await client.contacts.allowList.state(address: peerAddress) + } + public var version: Version { switch self { case .v1: @@ -82,7 +95,7 @@ public enum Conversation: Sendable { /// See Conversations.importTopicData() public func toTopicData() -> Xmtp_KeystoreApi_V1_TopicMap.TopicData { Xmtp_KeystoreApi_V1_TopicMap.TopicData.with { - $0.createdNs = UInt64(createdAt.timeIntervalSince1970 * 1_000) * 1_000_000 + $0.createdNs = UInt64(createdAt.timeIntervalSince1970 * 1000) * 1_000_000 $0.peerAddress = peerAddress if case let .v2(cv2) = self { $0.invitation = Xmtp_MessageContents_InvitationV1.with { @@ -123,16 +136,16 @@ public enum Conversation: Sendable { } } - // This is a convenience for invoking the underlying `client.publish(prepared.envelopes)` - // If a caller has a `Client` handy, they may opt to do that directly instead. - @discardableResult public func send(prepared: PreparedMessage) async throws -> String { - switch self { - case let .v1(conversationV1): - return try await conversationV1.send(prepared: prepared) - case let .v2(conversationV2): - return try await conversationV2.send(prepared: prepared) - } - } + // This is a convenience for invoking the underlying `client.publish(prepared.envelopes)` + // If a caller has a `Client` handy, they may opt to do that directly instead. + @discardableResult public func send(prepared: PreparedMessage) async throws -> String { + switch self { + case let .v1(conversationV1): + return try await conversationV1.send(prepared: prepared) + case let .v2(conversationV2): + return try await conversationV2.send(prepared: prepared) + } + } @discardableResult public func send(content: T, options: SendOptions? = nil, fallback _: String? = nil) async throws -> String { switch self { @@ -199,10 +212,10 @@ public enum Conversation: Sendable { } /// List messages in the conversation - public func messages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecodedMessage] { + public func messages(limit: Int? = nil, before: Date? = nil, after: Date? = nil, direction: PagingInfoSortDirection? = .descending) async throws -> [DecodedMessage] { switch self { case let .v1(conversationV1): - return try await conversationV1.messages(limit: limit, before: before, after: after, direction: direction) + return try await conversationV1.messages(limit: limit, before: before, after: after, direction: direction) case let .v2(conversationV2): return try await conversationV2.messages(limit: limit, before: before, after: after, direction: direction) } diff --git a/Sources/XMTP/Conversations.swift b/Sources/XMTP/Conversations.swift index 109e67ca..b5db19eb 100644 --- a/Sources/XMTP/Conversations.swift +++ b/Sources/XMTP/Conversations.swift @@ -155,6 +155,8 @@ public actor Conversations { let sealedInvitation = try await sendInvitation(recipient: recipient, invitation: invitation, created: Date()) let conversationV2 = try ConversationV2.create(client: client, invitation: invitation, header: sealedInvitation.v1.header) + await client.contacts.allow(addresses: [peerAddress]) + let conversation: Conversation = .v2(conversationV2) conversationsByTopic[conversation.topic] = conversation return conversation diff --git a/Tests/XMTPTests/ContactsTests.swift b/Tests/XMTPTests/ContactsTests.swift index 072bb415..d4006e87 100644 --- a/Tests/XMTPTests/ContactsTests.swift +++ b/Tests/XMTPTests/ContactsTests.swift @@ -53,4 +53,32 @@ class ContactsTests: XCTestCase { let hasContact = await fixtures.aliceClient.contacts.has(fixtures.bob.walletAddress) XCTAssert(hasContact) } + + func testAllowAddress() async throws { + let fixtures = await fixtures() + + let contacts = fixtures.bobClient.contacts + var result = await contacts.isAllowed(fixtures.alice.address) + + XCTAssertFalse(result) + + await contacts.allow(addresses: [fixtures.alice.address]) + + result = await contacts.isAllowed(fixtures.alice.address) + XCTAssertTrue(result) + } + + func testBlockAddress() async throws { + let fixtures = await fixtures() + + let contacts = fixtures.bobClient.contacts + var result = await contacts.isAllowed(fixtures.alice.address) + + XCTAssertFalse(result) + + await contacts.block(addresses: [fixtures.alice.address]) + + result = await contacts.isBlocked(fixtures.alice.address) + XCTAssertTrue(result) + } } diff --git a/Tests/XMTPTests/ConversationTests.swift b/Tests/XMTPTests/ConversationTests.swift index b6ede00a..4f213547 100644 --- a/Tests/XMTPTests/ConversationTests.swift +++ b/Tests/XMTPTests/ConversationTests.swift @@ -39,7 +39,7 @@ class ConversationTests: XCTestCase { let preparedMessage = try await conversation.prepareMessage(content: "hi") let messageID = preparedMessage.messageID - try await conversation.send(prepared: preparedMessage) + try await conversation.send(prepared: preparedMessage) let messages = try await conversation.messages() let message = messages[0] @@ -48,27 +48,27 @@ class ConversationTests: XCTestCase { XCTAssertEqual(message.id, messageID) } - func testCanSendPreparedMessagesWithoutAConversation() async throws { - let conversation = try await aliceClient.conversations.newConversation(with: bob.address) - let preparedMessage = try await conversation.prepareMessage(content: "hi") - let messageID = preparedMessage.messageID + func testCanSendPreparedMessagesWithoutAConversation() async throws { + let conversation = try await aliceClient.conversations.newConversation(with: bob.address) + let preparedMessage = try await conversation.prepareMessage(content: "hi") + let messageID = preparedMessage.messageID - // This does not need the `conversation` to `.publish` the message. - // This simulates a background task publishes all pending messages upon connection. - try await aliceClient.publish(envelopes: preparedMessage.envelopes) + // This does not need the `conversation` to `.publish` the message. + // This simulates a background task publishes all pending messages upon connection. + try await aliceClient.publish(envelopes: preparedMessage.envelopes) - let messages = try await conversation.messages() - let message = messages[0] + let messages = try await conversation.messages() + let message = messages[0] - XCTAssertEqual("hi", message.body) - XCTAssertEqual(message.id, messageID) - } + XCTAssertEqual("hi", message.body) + XCTAssertEqual(message.id, messageID) + } func testV2RejectsSpoofedContactBundles() async throws { let topic = "/xmtp/0/m-Gdb7oj5nNdfZ3MJFLAcS4WTABgr6al1hePy6JV1-QUE/proto" guard let envelopeMessage = Data(base64String: "Er0ECkcIwNruhKLgkKUXEjsveG10cC8wL20tR2RiN29qNW5OZGZaM01KRkxBY1M0V1RBQmdyNmFsMWhlUHk2SlYxLVFVRS9wcm90bxLxAwruAwognstLoG6LWgiBRsWuBOt+tYNJz+CqCj9zq6hYymLoak8SDFsVSy+cVAII0/r3sxq7A/GCOrVtKH6J+4ggfUuI5lDkFPJ8G5DHlysCfRyFMcQDIG/2SFUqSILAlpTNbeTC9eSI2hUjcnlpH9+ncFcBu8StGfmilVGfiADru2fGdThiQ+VYturqLIJQXCHO2DkvbbUOg9xI66E4Hj41R9vE8yRGeZ/eRGRLRm06HftwSQgzAYf2AukbvjNx/k+xCMqti49Qtv9AjzxVnwttLiA/9O+GDcOsiB1RQzbZZzaDjQ/nLDTF6K4vKI4rS9QwzTJqnoCdp0SbMZFf+KVZpq3VWnMGkMxLW5Fr6gMvKny1e1LAtUJSIclI/1xPXu5nsKd4IyzGb2ZQFXFQ/BVL9Z4CeOZTsjZLGTOGS75xzzGHDtKohcl79+0lgIhAuSWSLDa2+o2OYT0fAjChp+qqxXcisAyrD5FB6c9spXKfoDZsqMV/bnCg3+udIuNtk7zBk7jdTDMkofEtE3hyIm8d3ycmxKYOakDPqeo+Nk1hQ0ogxI8Z7cEoS2ovi9+rGBMwREzltUkTVR3BKvgV2EOADxxTWo7y8WRwWxQ+O6mYPACsiFNqjX5Nvah5lRjihphQldJfyVOG8Rgf4UwkFxmI"), - let keyMaterial = Data(base64String: "R0BBM5OPftNEuavH/991IKyJ1UqsgdEG4SrdxlIG2ZY=") + let keyMaterial = Data(base64String: "R0BBM5OPftNEuavH/991IKyJ1UqsgdEG4SrdxlIG2ZY=") else { XCTFail("did not have correct setup data") return @@ -88,7 +88,7 @@ class ConversationTests: XCTestCase { } func testDoesNotAllowConversationWithSelf() async throws { - try TestConfig.skipIfNotRunningLocalNodeTests() + try TestConfig.skipIfNotRunningLocalNodeTests() let expectation = expectation(description: "convo with self throws") let client = aliceClient! @@ -221,12 +221,12 @@ class ConversationTests: XCTestCase { let encodedContent = try encoder.encode(content: "hi alice", client: aliceClient) // Stream a message - fakeApiClient.send( + try fakeApiClient.send( envelope: Envelope( topic: conversation.topic, timestamp: Date(), - message: try Message( - v2: try await MessageV2.encode( + message: Message( + v2: await MessageV2.encode( client: bobClient, content: encodedContent, topic: conversation.topic, @@ -292,7 +292,7 @@ class ConversationTests: XCTestCase { ) try await aliceClient.publish(envelopes: [ - Envelope(topic: aliceConversation.topic, timestamp: Date(), message: try Message(v2: tamperedMessage).serializedData()), + Envelope(topic: aliceConversation.topic, timestamp: Date(), message: Message(v2: tamperedMessage).serializedData()), ]) guard case let .v2(bobConversation) = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) else { @@ -343,7 +343,7 @@ class ConversationTests: XCTestCase { let messages = try await aliceConversation.messages(limit: 1) XCTAssertEqual(1, messages.count) XCTAssertEqual("hey alice 3", messages[0].body) - XCTAssertEqual(aliceConversation.topic.description, messages[0].topic) + XCTAssertEqual(aliceConversation.topic.description, messages[0].topic) let messages2 = try await aliceConversation.messages(limit: 1, before: messages[0].sent) XCTAssertEqual(1, messages2.count) @@ -385,7 +385,7 @@ class ConversationTests: XCTestCase { let messages = try await aliceConversation.messages(limit: 1) XCTAssertEqual(1, messages.count) XCTAssertEqual("hey alice 3", messages[0].body) - XCTAssertEqual(aliceConversation.topic, messages[0].topic) + XCTAssertEqual(aliceConversation.topic, messages[0].topic) let messages2 = try await aliceConversation.messages(limit: 1, before: messages[0].sent) XCTAssertEqual(1, messages2.count) @@ -393,7 +393,6 @@ class ConversationTests: XCTestCase { } func testCanRetrieveAllMessages() async throws { - guard case let .v2(bobConversation) = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) else { XCTFail("did not get a v2 conversation for bob") return @@ -404,7 +403,7 @@ class ConversationTests: XCTestCase { return } - for i in 0..<110 { + for i in 0 ..< 110 { do { let content = "hey alice \(i)" let sentAt = Date().addingTimeInterval(-1000) @@ -423,61 +422,60 @@ class ConversationTests: XCTestCase { let messages = try await aliceConversation.messages() XCTAssertEqual(110, messages.count) } - - func testCanRetrieveBatchMessages() async throws { - - guard case let .v2(bobConversation) = try await aliceClient.conversations.newConversation(with: bob.address, context: InvitationV1.Context(conversationID: "hi")) else { - XCTFail("did not get a v2 conversation for bob") - return - } - - for i in 0..<3 { - do { - let content = "hey alice \(i)" - let sentAt = Date().addingTimeInterval(-1000) - try await bobConversation.send(content: content, sentAt: sentAt) - } catch { - print("Error sending message:", error) - } - } - - let messages = try await aliceClient.conversations.listBatchMessages( - topics: [bobConversation.topic : Pagination(limit:3)] - ) - XCTAssertEqual(3, messages.count) - XCTAssertEqual(bobConversation.topic, messages[0].topic) - XCTAssertEqual(bobConversation.topic, messages[1].topic) - XCTAssertEqual(bobConversation.topic, messages[2].topic) - } - - func testProperlyDiscardBadBatchMessages() async throws { - - guard case let .v2(bobConversation) = try await aliceClient.conversations - .newConversation(with: bob.address) else { - XCTFail("did not get a v2 conversation for bob") - return - } - - try await bobConversation.send(content: "Hello") - - // Now we send some garbage and expect it to be properly ignored. - try await bobClient.apiClient.publish(envelopes: [ - Envelope( - topic: bobConversation.topic, - timestamp: Date(), - message: Data([1, 2, 3]) // garbage, malformed message - ) - ]) - - try await bobConversation.send(content: "Goodbye") - - let messages = try await aliceClient.conversations.listBatchMessages( - topics: [bobConversation.topic : nil] - ) - XCTAssertEqual(2, messages.count) - XCTAssertEqual("Goodbye", try messages[0].content()) - XCTAssertEqual("Hello", try messages[1].content()) - } + + func testCanRetrieveBatchMessages() async throws { + guard case let .v2(bobConversation) = try await aliceClient.conversations.newConversation(with: bob.address, context: InvitationV1.Context(conversationID: "hi")) else { + XCTFail("did not get a v2 conversation for bob") + return + } + + for i in 0 ..< 3 { + do { + let content = "hey alice \(i)" + let sentAt = Date().addingTimeInterval(-1000) + try await bobConversation.send(content: content, sentAt: sentAt) + } catch { + print("Error sending message:", error) + } + } + + let messages = try await aliceClient.conversations.listBatchMessages( + topics: [bobConversation.topic: Pagination(limit: 3)] + ) + XCTAssertEqual(3, messages.count) + XCTAssertEqual(bobConversation.topic, messages[0].topic) + XCTAssertEqual(bobConversation.topic, messages[1].topic) + XCTAssertEqual(bobConversation.topic, messages[2].topic) + } + + func testProperlyDiscardBadBatchMessages() async throws { + guard case let .v2(bobConversation) = try await aliceClient.conversations + .newConversation(with: bob.address) + else { + XCTFail("did not get a v2 conversation for bob") + return + } + + try await bobConversation.send(content: "Hello") + + // Now we send some garbage and expect it to be properly ignored. + try await bobClient.apiClient.publish(envelopes: [ + Envelope( + topic: bobConversation.topic, + timestamp: Date(), + message: Data([1, 2, 3]) // garbage, malformed message + ), + ]) + + try await bobConversation.send(content: "Goodbye") + + let messages = try await aliceClient.conversations.listBatchMessages( + topics: [bobConversation.topic: nil] + ) + XCTAssertEqual(2, messages.count) + XCTAssertEqual("Goodbye", try messages[0].content()) + XCTAssertEqual("Hello", try messages[1].content()) + } func testImportV1ConversationFromJS() async throws { let jsExportJSONData = Data(""" @@ -607,4 +605,30 @@ class ConversationTests: XCTestCase { XCTAssertEqual(Array(repeating: "A", count: 1000).joined(), messages[0].body) XCTAssertEqual(bob.address, messages[0].senderAddress) } + + func testCanHaveAllowState() async throws { + let bobConversation = try await bobClient.conversations.newConversation(with: alice.address, context: InvitationV1.Context(conversationID: "hi")) + let isAllowed = (await bobConversation.allowState()) == .allowed + + // Conversations you start should start as allowed + XCTAssertTrue(isAllowed) + + let aliceConversation = (try await aliceClient.conversations.list())[0] + let isUnknown = (await aliceConversation.allowState()) == .unknown + + // Conversations started with you should start as unknown + XCTAssertTrue(isUnknown) + + await aliceClient.contacts.allow(addresses: [bob.address]) + + let isBobAllowed = (await aliceConversation.allowState()) == .allowed + XCTAssertTrue(isBobAllowed) + + let aliceClient2 = try await Client.create(account: alice, apiClient: fakeApiClient) + let aliceConversation2 = (try await aliceClient2.conversations.list())[0] + + // Allow state should sync across clients + let isBobAllowed2 = (await aliceConversation2.allowState()) == .allowed + XCTAssertTrue(isBobAllowed2) + } }