mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
fix: keep NDR acknowledgements on ratchet sessions
This commit is contained in:
parent
1e766b8d4f
commit
9abb8fce0f
@ -140,7 +140,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
/// Ack pacing shared across transport instances. Geohash acks are sent
|
||||
/// through short-lived transports created per ack
|
||||
/// (`makeGeohashNostrTransport()`), so a per-instance queue would only
|
||||
/// (`makeNostrTransport()`), so a per-instance queue would only
|
||||
/// ever hold one item and never pace a burst (flagged by Codex on
|
||||
/// #1398). Production wires `sharedAckPacer` via `Dependencies.live`;
|
||||
/// tests get an isolated instance per `Dependencies` by default.
|
||||
|
||||
@ -94,6 +94,8 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID)
|
||||
|
||||
// MARK: System messages
|
||||
func addMeshOnlySystemMessage(_ content: String)
|
||||
@ -185,7 +187,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
}
|
||||
|
||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
makeGeohashNostrTransport().sendPrivateMessageGeohash(
|
||||
makeNostrTransport().sendPrivateMessageGeohash(
|
||||
content: content,
|
||||
toRecipientHex: recipientHex,
|
||||
from: identity,
|
||||
@ -194,11 +196,24 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
}
|
||||
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
makeGeohashNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
|
||||
makeNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
|
||||
}
|
||||
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
|
||||
makeNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
|
||||
}
|
||||
|
||||
func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
makeNostrTransport().sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
|
||||
func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: messageID,
|
||||
readerID: myPeerID,
|
||||
readerNickname: nickname
|
||||
)
|
||||
makeNostrTransport().sendReadReceipt(receipt, to: peerID)
|
||||
}
|
||||
|
||||
func addSystemMessage(_ content: String) {
|
||||
@ -226,7 +241,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
|
||||
}
|
||||
|
||||
private func makeGeohashNostrTransport() -> NostrTransport {
|
||||
private func makeNostrTransport() -> NostrTransport {
|
||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
transport.senderPeerID = meshService.myPeerID
|
||||
return transport
|
||||
@ -486,7 +501,8 @@ final class ChatPrivateConversationCoordinator {
|
||||
senderPubkey: String,
|
||||
convKey: PeerID,
|
||||
id: NostrIdentity,
|
||||
messageTimestamp: Date
|
||||
messageTimestamp: Date,
|
||||
source: NostrPrivateMessageSource = .legacy1059
|
||||
) {
|
||||
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||
let messageId = pm.messageID
|
||||
@ -494,7 +510,13 @@ final class ChatPrivateConversationCoordinator {
|
||||
// Ack before the dedup guard: a re-sent copy means the sender may not
|
||||
// have our DELIVERED yet, and markGeoDeliveryAckSent dedups the
|
||||
// actual sends.
|
||||
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
||||
sendDeliveryAckIfNeeded(
|
||||
to: messageId,
|
||||
senderPubKey: senderPubkey,
|
||||
conversationPeerID: convKey,
|
||||
from: id,
|
||||
source: source
|
||||
)
|
||||
|
||||
guard markInboundGeoDMSeen(messageId) else { return }
|
||||
|
||||
@ -555,7 +577,13 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
if isViewing {
|
||||
sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
||||
sendReadReceiptIfNeeded(
|
||||
to: messageId,
|
||||
senderPubKey: senderPubkey,
|
||||
conversationPeerID: convKey,
|
||||
from: id,
|
||||
source: source
|
||||
)
|
||||
}
|
||||
|
||||
if !isViewing && shouldMarkUnread {
|
||||
@ -633,14 +661,50 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||
func sendDeliveryAckIfNeeded(
|
||||
to messageId: String,
|
||||
senderPubKey: String,
|
||||
conversationPeerID: PeerID,
|
||||
from id: NostrIdentity,
|
||||
source: NostrPrivateMessageSource
|
||||
) {
|
||||
guard context.markGeoDeliveryAckSent(messageId) else { return }
|
||||
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
|
||||
switch source {
|
||||
case .ndr:
|
||||
context.sendAccountNostrDeliveryAck(
|
||||
for: messageId,
|
||||
to: conversationPeerID
|
||||
)
|
||||
case .legacy1059:
|
||||
context.sendGeohashDeliveryAck(
|
||||
for: messageId,
|
||||
toRecipientHex: senderPubKey,
|
||||
from: id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||
func sendReadReceiptIfNeeded(
|
||||
to messageId: String,
|
||||
senderPubKey: String,
|
||||
conversationPeerID: PeerID,
|
||||
from id: NostrIdentity,
|
||||
source: NostrPrivateMessageSource
|
||||
) {
|
||||
guard context.markReadReceiptSent(messageId) else { return }
|
||||
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
||||
switch source {
|
||||
case .ndr:
|
||||
context.sendAccountNostrReadReceipt(
|
||||
for: messageId,
|
||||
to: conversationPeerID
|
||||
)
|
||||
case .legacy1059:
|
||||
context.sendGeohashReadReceipt(
|
||||
messageId,
|
||||
toRecipientHex: senderPubKey,
|
||||
from: id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||
|
||||
@ -34,14 +34,16 @@ extension ChatViewModel {
|
||||
senderPubkey: String,
|
||||
convKey: PeerID,
|
||||
id: NostrIdentity,
|
||||
messageTimestamp: Date
|
||||
messageTimestamp: Date,
|
||||
source: NostrPrivateMessageSource = .legacy1059
|
||||
) {
|
||||
privateConversationCoordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: id,
|
||||
messageTimestamp: messageTimestamp
|
||||
messageTimestamp: messageTimestamp,
|
||||
source: source
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,11 @@ import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
enum NostrPrivateMessageSource: Equatable {
|
||||
case legacy1059
|
||||
case ndr
|
||||
}
|
||||
|
||||
/// The narrow surface `NostrInboundPipeline` needs from its owner.
|
||||
///
|
||||
/// Split out of `ChatNostrContext`: member names are shared with the sibling
|
||||
@ -51,7 +56,8 @@ protocol NostrInboundPipelineContext: AnyObject {
|
||||
senderPubkey: String,
|
||||
convKey: PeerID,
|
||||
id: NostrIdentity,
|
||||
messageTimestamp: Date
|
||||
messageTimestamp: Date,
|
||||
source: NostrPrivateMessageSource
|
||||
)
|
||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
||||
@ -427,7 +433,8 @@ final class NostrInboundPipeline {
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: id,
|
||||
messageTimestamp: messageTimestamp
|
||||
messageTimestamp: messageTimestamp,
|
||||
source: .legacy1059
|
||||
)
|
||||
case .delivered:
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
@ -493,7 +500,7 @@ final class NostrInboundPipeline {
|
||||
currentIdentity: currentIdentity,
|
||||
wipeGeneration: wipeGeneration,
|
||||
expiresAtSeconds: message.expiresAtSeconds,
|
||||
requiresFavoriteBinding: true
|
||||
source: .ndr
|
||||
)
|
||||
guard self.wipeGeneration == wipeGeneration else {
|
||||
completion(.retry)
|
||||
@ -548,7 +555,8 @@ final class NostrInboundPipeline {
|
||||
senderPubkey: senderPubkey,
|
||||
rumorTimestamp: rumorTimestamp,
|
||||
currentIdentity: currentIdentity,
|
||||
wipeGeneration: wipeGeneration
|
||||
wipeGeneration: wipeGeneration,
|
||||
source: .legacy1059
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
|
||||
@ -563,7 +571,7 @@ final class NostrInboundPipeline {
|
||||
currentIdentity: NostrIdentity,
|
||||
wipeGeneration: UInt64,
|
||||
expiresAtSeconds: UInt64? = nil,
|
||||
requiresFavoriteBinding: Bool = false
|
||||
source: NostrPrivateMessageSource
|
||||
) async -> NdrDeliveryDisposition {
|
||||
guard let context else { return .retry }
|
||||
if content.hasPrefix("verify:") {
|
||||
@ -587,7 +595,7 @@ final class NostrInboundPipeline {
|
||||
let actualSenderNoiseKey: Data? = await MainActor.run {
|
||||
self.findNoiseKey(for: routingPubkey)
|
||||
}
|
||||
if requiresFavoriteBinding, actualSenderNoiseKey == nil {
|
||||
if source == .ndr, actualSenderNoiseKey == nil {
|
||||
// Keep the native delivery durable until the favorite binding
|
||||
// journal is recovered. Falling through to a virtual Nostr peer
|
||||
// would bypass the fail-closed pairwise identity binding.
|
||||
@ -626,7 +634,8 @@ final class NostrInboundPipeline {
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: targetPeerID,
|
||||
id: currentIdentity,
|
||||
messageTimestamp: messageTimestamp
|
||||
messageTimestamp: messageTimestamp,
|
||||
source: source
|
||||
)
|
||||
case .delivered:
|
||||
context.handleDelivered(
|
||||
|
||||
@ -79,7 +79,15 @@ private final class MockChatNostrContext: ChatNostrContext {
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var nostrKeyMapping: [PeerID: String] = [:]
|
||||
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
|
||||
private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = []
|
||||
private(set) var handledPrivateMessages: [
|
||||
(
|
||||
payload: NoisePayload,
|
||||
senderPubkey: String,
|
||||
convKey: PeerID,
|
||||
timestamp: Date,
|
||||
source: NostrPrivateMessageSource
|
||||
)
|
||||
] = []
|
||||
private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
|
||||
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
|
||||
private(set) var startedPrivateChats: [PeerID] = []
|
||||
@ -89,9 +97,16 @@ private final class MockChatNostrContext: ChatNostrContext {
|
||||
senderPubkey: String,
|
||||
convKey: PeerID,
|
||||
id: NostrIdentity,
|
||||
messageTimestamp: Date
|
||||
messageTimestamp: Date,
|
||||
source: NostrPrivateMessageSource
|
||||
) {
|
||||
handledPrivateMessages.append((payload, senderPubkey, convKey, messageTimestamp))
|
||||
handledPrivateMessages.append((
|
||||
payload,
|
||||
senderPubkey,
|
||||
convKey,
|
||||
messageTimestamp,
|
||||
source
|
||||
))
|
||||
}
|
||||
|
||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||
@ -367,6 +382,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||
#expect(context.handledPrivateMessages.first?.source == .legacy1059)
|
||||
|
||||
// The embedded Noise payload survives the round trip intact.
|
||||
let payload = try #require(context.handledPrivateMessages.first?.payload)
|
||||
@ -488,6 +504,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
await coordinator.inbound.processNostrMessage(acceptedGiftWrap)
|
||||
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
#expect(context.handledPrivateMessages.first?.source == .legacy1059)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@ -731,6 +748,17 @@ struct GeoPresenceTrackerTests {
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
context.nostrIdentity = recipient
|
||||
let senderNoiseKey = Data(repeating: 0xA8, count: 32)
|
||||
context.favoriteRelationshipsByNoiseKey[senderNoiseKey] =
|
||||
FavoritesPersistenceService.FavoriteRelationship(
|
||||
peerNoisePublicKey: senderNoiseKey,
|
||||
peerNostrPublicKey: sender.npub,
|
||||
peerNickname: "expired-peer",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true,
|
||||
favoritedAt: Date(timeIntervalSince1970: 0),
|
||||
lastUpdated: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
let senderPeerID = PeerID(str: "0011223344556677")
|
||||
let embedded = try #require(
|
||||
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
@ -781,4 +809,64 @@ struct GeoPresenceTrackerTests {
|
||||
#expect(context.recordedNostrEventIDs == [rumor.id])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func ndrDelivery_marksPrivateMessageAsPairwiseOnly() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let noiseKey = Data(repeating: 0xA9, count: 32)
|
||||
context.nostrIdentity = recipient
|
||||
context.favoriteRelationshipsByNoiseKey[noiseKey] =
|
||||
FavoritesPersistenceService.FavoriteRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: sender.npub,
|
||||
peerNickname: "pairwise-peer",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true,
|
||||
favoritedAt: Date(timeIntervalSince1970: 0),
|
||||
lastUpdated: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
let embedded = try #require(
|
||||
NostrEmbeddedBitChat.encodePMForNostr(
|
||||
content: "pairwise inbound",
|
||||
messageID: "ndr-inbound-1",
|
||||
recipientPeerID: PeerID(hexData: noiseKey),
|
||||
senderPeerID: PeerID(str: "0011223344556677")
|
||||
)
|
||||
)
|
||||
var rumor = try NostrEvent(
|
||||
pubkey: sender.publicKeyHex,
|
||||
createdAt: Date(timeIntervalSince1970: 99),
|
||||
kind: .dm,
|
||||
tags: [],
|
||||
content: embedded
|
||||
).sign(with: sender.schnorrSigningKey())
|
||||
rumor.sig = nil
|
||||
let pipeline = NostrInboundPipeline(
|
||||
context: context,
|
||||
presence: GeoPresenceTracker(context: context)
|
||||
)
|
||||
var disposition: NdrDeliveryDisposition?
|
||||
|
||||
pipeline.handleNdrDecryptedMessage(
|
||||
NdrDecryptedMessage(
|
||||
event: rumor,
|
||||
senderPubkeyHex: sender.publicKeyHex,
|
||||
outerEventID: String(repeating: "c", count: 64),
|
||||
expiresAtSeconds: nil
|
||||
),
|
||||
completion: { disposition = $0 }
|
||||
)
|
||||
let completed = await TestHelpers.waitUntil(
|
||||
{ disposition != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
|
||||
#expect(completed)
|
||||
#expect(disposition == .consumed)
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
#expect(context.handledPrivateMessages.first?.convKey == PeerID(hexData: noiseKey))
|
||||
#expect(context.handledPrivateMessages.first?.source == .ndr)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -176,6 +176,8 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
|
||||
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
|
||||
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
||||
private(set) var accountNostrDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||
private(set) var accountNostrReadReceipts: [(messageID: String, peerID: PeerID)] = []
|
||||
var queuedMessageIDsByPeerID: [PeerID: Set<String>] = [:]
|
||||
private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = []
|
||||
private(set) var deliveredMessageIDs: [String] = []
|
||||
@ -222,6 +224,14 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
geoReadReceipts.append((messageID, recipientHex))
|
||||
}
|
||||
|
||||
func sendAccountNostrDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
accountNostrDeliveryAcks.append((messageID, peerID))
|
||||
}
|
||||
|
||||
func sendAccountNostrReadReceipt(for messageID: String, to peerID: PeerID) {
|
||||
accountNostrReadReceipts.append((messageID, peerID))
|
||||
}
|
||||
|
||||
// Favorites & notifications
|
||||
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
|
||||
private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = []
|
||||
@ -474,6 +484,8 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.geoDeliveryAcks.map(\.messageID) == ["geo-1"])
|
||||
#expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey)
|
||||
#expect(context.sentGeoDeliveryAcks == ["geo-1"])
|
||||
#expect(context.accountNostrDeliveryAcks.isEmpty)
|
||||
#expect(context.accountNostrReadReceipts.isEmpty)
|
||||
#expect(context.privateChats[convKey]?.map(\.id) == ["geo-1"])
|
||||
#expect(context.privateChats[convKey]?.first?.sender == "bob#5678")
|
||||
#expect(context.unreadPrivateMessages.isEmpty)
|
||||
@ -491,6 +503,91 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.privateChats[convKey]?.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func ndrPrivateMessage_sendsOnlyPairwiseDeliveryAndReadAcks() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let stablePeerID = PeerID(
|
||||
hexData: Data(repeating: 0xA6, count: 32)
|
||||
)
|
||||
let senderPubkey = String(repeating: "b", count: 64)
|
||||
context.selectedPrivateChatPeer = stablePeerID
|
||||
context.displayNamesByPubkey[senderPubkey] = "bob"
|
||||
let payloadData = PrivateMessagePacket(
|
||||
messageID: "ndr-ack-1",
|
||||
content: "pairwise"
|
||||
).encode()!
|
||||
let payload = NoisePayload(
|
||||
type: .privateMessage,
|
||||
data: payloadData
|
||||
)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: stablePeerID,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date(),
|
||||
source: .ndr
|
||||
)
|
||||
|
||||
#expect(context.accountNostrDeliveryAcks.count == 1)
|
||||
#expect(context.accountNostrDeliveryAcks.first?.messageID == "ndr-ack-1")
|
||||
#expect(context.accountNostrDeliveryAcks.first?.peerID == stablePeerID)
|
||||
#expect(context.accountNostrReadReceipts.count == 1)
|
||||
#expect(context.accountNostrReadReceipts.first?.messageID == "ndr-ack-1")
|
||||
#expect(context.accountNostrReadReceipts.first?.peerID == stablePeerID)
|
||||
#expect(context.geoDeliveryAcks.isEmpty)
|
||||
#expect(context.geoReadReceipts.isEmpty)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: stablePeerID,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date(),
|
||||
source: .ndr
|
||||
)
|
||||
|
||||
#expect(context.accountNostrDeliveryAcks.count == 1)
|
||||
#expect(context.accountNostrReadReceipts.count == 1)
|
||||
#expect(context.privateChats[stablePeerID]?.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func ndrPrivateMessage_notViewingSendsOnlyPairwiseDeliveryAck() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let stablePeerID = PeerID(
|
||||
hexData: Data(repeating: 0xA5, count: 32)
|
||||
)
|
||||
let senderPubkey = String(repeating: "c", count: 64)
|
||||
context.displayNamesByPubkey[senderPubkey] = "carol"
|
||||
let payload = NoisePayload(
|
||||
type: .privateMessage,
|
||||
data: PrivateMessagePacket(
|
||||
messageID: "ndr-background-1",
|
||||
content: "background"
|
||||
).encode()!
|
||||
)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: stablePeerID,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date(),
|
||||
source: .ndr
|
||||
)
|
||||
|
||||
#expect(context.accountNostrDeliveryAcks.count == 1)
|
||||
#expect(context.accountNostrDeliveryAcks.first?.peerID == stablePeerID)
|
||||
#expect(context.accountNostrReadReceipts.isEmpty)
|
||||
#expect(context.geoDeliveryAcks.isEmpty)
|
||||
#expect(context.geoReadReceipts.isEmpty)
|
||||
#expect(context.unreadPrivateMessages == Set([stablePeerID]))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func accountDM_handsOpenShortIDConversationToStableWhenOffline() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
|
||||
@ -684,7 +684,14 @@ private final class PerfNostrContext: ChatNostrContext {
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var nostrKeyMapping: [PeerID: String] = [:]
|
||||
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
|
||||
func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {}
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
senderPubkey: String,
|
||||
convKey: PeerID,
|
||||
id: NostrIdentity,
|
||||
messageTimestamp: Date,
|
||||
source: NostrPrivateMessageSource
|
||||
) {}
|
||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
||||
func startPrivateChat(with peerID: PeerID) {}
|
||||
|
||||
@ -744,6 +744,126 @@ struct NostrTransportTests {
|
||||
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
|
||||
}
|
||||
|
||||
@Test("Direct delivery and read ACKs stay on an established NDR session")
|
||||
@MainActor
|
||||
func directAcksUseNdrWhenSessionExists() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let senderRelay = FakeRelayManager()
|
||||
let recipientRelay = FakeRelayManager()
|
||||
let senderNdr = NdrNostrService(
|
||||
relayManager: senderRelay,
|
||||
rolloutEnabled: true,
|
||||
storageDirectoryProvider: {
|
||||
try makeTempDir(label: "direct-ack-ndr-sender")
|
||||
}
|
||||
)
|
||||
let recipientNdr = NdrNostrService(
|
||||
relayManager: recipientRelay,
|
||||
rolloutEnabled: true,
|
||||
storageDirectoryProvider: {
|
||||
try makeTempDir(label: "direct-ack-ndr-recipient")
|
||||
}
|
||||
)
|
||||
senderNdr.configureIfNeeded(identity: sender)
|
||||
recipientNdr.configureIfNeeded(identity: recipient)
|
||||
try establishMutualSession(
|
||||
senderNdr,
|
||||
recipientNdr,
|
||||
senderIdentity: sender,
|
||||
recipientIdentity: recipient,
|
||||
senderRelay: senderRelay,
|
||||
recipientRelay: recipientRelay
|
||||
)
|
||||
senderRelay.resetSentEvents()
|
||||
|
||||
let noiseKey = Data((144..<176).map(UInt8.init))
|
||||
let peerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Ack peer"
|
||||
)
|
||||
let legacyProbe = NostrTransportProbe()
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(keychain: keychain),
|
||||
ndrService: senderNdr,
|
||||
dependencies: makeDependencies(
|
||||
favoriteStatusForNoiseKey: {
|
||||
$0 == noiseKey ? relationship : nil
|
||||
},
|
||||
isNdrFallbackBlockedForPeerID: {
|
||||
$0.toShort() == peerID.toShort()
|
||||
},
|
||||
currentIdentity: { sender },
|
||||
sendEvent: legacyProbe.record(event:),
|
||||
scheduleAfter: { delay, action in
|
||||
legacyProbe.enqueueScheduledAction(
|
||||
delay: delay,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
transport.senderPeerID = PeerID(str: "0123456789abcdef")
|
||||
var decryptedMessages: [NdrDecryptedMessage] = []
|
||||
recipientNdr.onDecryptedMessage = { message, completion in
|
||||
decryptedMessages.append(message)
|
||||
completion(.consumed)
|
||||
}
|
||||
|
||||
transport.sendDeliveryAck(for: "ndr-delivered-1", to: peerID)
|
||||
let deliveredSent = await TestHelpers.waitUntil({
|
||||
senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 1
|
||||
})
|
||||
#expect(deliveredSent)
|
||||
let deliveredOuter = try #require(
|
||||
senderRelay.sentEvents.first { $0.kind == 1060 }
|
||||
)
|
||||
recipientNdr.processInboundRelayEvent(deliveredOuter)
|
||||
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: "ndr-read-1",
|
||||
readerID: transport.myPeerID,
|
||||
readerNickname: "me"
|
||||
)
|
||||
transport.sendReadReceipt(receipt, to: peerID)
|
||||
let readQueued = await TestHelpers.waitUntil({
|
||||
legacyProbe.scheduledActionCount == 1
|
||||
})
|
||||
#expect(readQueued)
|
||||
#expect(legacyProbe.runNextScheduledAction())
|
||||
let readSent = await TestHelpers.waitUntil({
|
||||
senderRelay.sentEvents.filter { $0.kind == 1060 }.count == 2
|
||||
})
|
||||
#expect(readSent)
|
||||
let readOuter = try #require(
|
||||
senderRelay.sentEvents.filter { $0.kind == 1060 }.last
|
||||
)
|
||||
recipientNdr.processInboundRelayEvent(readOuter)
|
||||
|
||||
#expect(legacyProbe.sentEvents.isEmpty)
|
||||
#expect(decryptedMessages.count == 2)
|
||||
let deliveredPayload = try decodeNdrEmbeddedPayload(
|
||||
from: decryptedMessages[0].event.content
|
||||
)
|
||||
#expect(deliveredPayload.type == .delivered)
|
||||
#expect(
|
||||
String(data: deliveredPayload.data, encoding: .utf8)
|
||||
== "ndr-delivered-1"
|
||||
)
|
||||
let readPayload = try decodeNdrEmbeddedPayload(
|
||||
from: decryptedMessages[1].event.content
|
||||
)
|
||||
#expect(readPayload.type == .readReceipt)
|
||||
#expect(
|
||||
String(data: readPayload.data, encoding: .utf8)
|
||||
== "ndr-read-1"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Geohash private message registers pending gift wrap")
|
||||
@MainActor
|
||||
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
|
||||
@ -1066,6 +1186,21 @@ struct NostrTransportTests {
|
||||
return (packet, payload, senderPubkey)
|
||||
}
|
||||
|
||||
private func decodeNdrEmbeddedPayload(
|
||||
from content: String
|
||||
) throws -> NoisePayload {
|
||||
guard content.hasPrefix("bitchat1:") else {
|
||||
throw NostrTransportTestError.invalidEmbeddedContent
|
||||
}
|
||||
let encoded = String(content.dropFirst("bitchat1:".count))
|
||||
guard let packetData = base64URLDecode(encoded),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
let payload = NoisePayload.decode(packet.payload) else {
|
||||
throw NostrTransportTestError.invalidPacket
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private func decodePrivateMessage(from payload: NoisePayload) throws -> PrivateMessagePacket {
|
||||
guard payload.type == .privateMessage,
|
||||
let message = PrivateMessagePacket.decode(from: payload.data) else {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user