From 840868d3152f8b49424fe2a742a998f67fae8a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 15:42:52 +0000 Subject: [PATCH] fix: fall back to observed relays when public chat declares an empty relay list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublicChatChannel.relays() was `info.relays?.toSet() ?: super.relays()`. An empty (non-null) declared-relay list — `emptyList()?.toSet()` — yields an empty set and short-circuits the elvis, so the channel reported zero relays instead of falling back to the relays it was actually observed on. Both the message-send path and the broadcast path (computeRelaysForChannels / wantsBroadcastRelays) read relays(), so the message was published to nowhere while a manual broadcast still reached the user's personal relays — matching the reported symptom. Treat an empty declared list like "no declared relays" via ifEmpty, so it falls back to observed relays. Adds PublicChatChannelRelayTest covering the declared-relay round-trip, message->channel resolution, and the empty-list fallback regression. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AYtHYEob2THu74inTxZxCh --- .../nip28PublicChats/PublicChatChannel.kt | 6 +- .../model/PublicChatChannelRelayTest.kt | 97 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/PublicChatChannelRelayTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatChannel.kt index ce715db39e..f7d0d20680 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatChannel.kt @@ -49,7 +49,11 @@ class PublicChatChannel( var infoTags = EmptyTagList var updatedMetadataAt: Long = 0 - override fun relays() = info.relays?.toSet() ?: super.relays() + // An empty declared-relay list must behave like "no declared relays" and fall + // back to the relays the channel was actually observed on. Without ifEmpty, + // `emptyList()?.toSet()` short-circuits the elvis to an empty set, so the + // channel reports zero relays and messages get published to nowhere. + override fun relays() = info.relays?.ifEmpty { null }?.toSet() ?: super.relays() fun relayHintUrls() = relays().take(3) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/PublicChatChannelRelayTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/PublicChatChannelRelayTest.kt new file mode 100644 index 0000000000..18358b7ff7 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/PublicChatChannelRelayTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Reproduces the "public chat message not sent to the channel-declared relay" report. + * + * Walks the exact protocol path the app uses: + * 1. build a kind-40 ChannelCreate carrying a declared relay in its content, + * 2. feed it to a PublicChatChannel via updateChannelInfo (what LocalCache does), + * 3. assert channel.relays() returns the declared relay (what both the send path + * and computeRelaysForChannels/broadcast read), + * 4. build a kind-42 message for that channel and assert channelId() resolves back + * to the channel id (what getAnyChannel uses to find the channel at broadcast). + */ +class PublicChatChannelRelayTest { + private val authorKey = "e".repeat(64) + private val channelId = "a".repeat(64) + private val msgId = "b".repeat(64) + private val sig = "f".repeat(128) + + private val declaredRelay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + private fun createEvent(relays: List?): ChannelCreateEvent { + val template = ChannelCreateEvent.build("My Channel", "about", null, relays) + return ChannelCreateEvent(channelId, authorKey, template.createdAt, template.tags, template.content, sig) + } + + @Test + fun channelRelaysContainsDeclaredRelay() { + val create = createEvent(listOf(declaredRelay)) + + // sanity: the relay survives the content round-trip + assertEquals(listOf(declaredRelay), create.channelInfo().relays) + + val channel = PublicChatChannel(channelId) + channel.info = create.channelInfo() // exactly what updateChannelInfo does + + assertTrue(channel.relays().contains(declaredRelay), "channel.relays() must contain the declared relay") + } + + @Test + fun messageResolvesBackToChannel() { + val create = createEvent(listOf(declaredRelay)) + + val template = ChannelMessageEvent.message("hello", EventHintBundle(create, declaredRelay)) + val message = ChannelMessageEvent(msgId, authorKey, template.createdAt, template.tags, template.content, sig) + + assertEquals(channelId, message.channelId(), "message must resolve to the channel id (getAnyChannel)") + } + + @Test + fun emptyDeclaredRelayListFallsThroughToObservedRelays() { + // Regression: info.relays = [] (empty, not null) must NOT short-circuit to an + // empty set. It has to fall back to the relays the channel was observed on, + // otherwise messages are published to nowhere. + val create = createEvent(emptyList()) + + val channel = PublicChatChannel(channelId) + channel.addRelaySync(declaredRelay) // observed relay + channel.info = create.channelInfo() // exactly what updateChannelInfo does + + assertTrue(channel.info.relays!!.isEmpty(), "precondition: declared relay list is empty") + assertTrue( + channel.relays().contains(declaredRelay), + "relays() must fall back to observed relays when the declared list is empty", + ) + } +}