From 11ba03334c8247e073010b6e101837e72ba6a317 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 21:40:23 +0000 Subject: [PATCH 1/5] fix: classify Marmot groups in Messages Known/New tabs by admin follow Before, every Marmot group landed unconditionally under the Known tab of the Messages screen while the New Requests tab ignored groups entirely, so an invite from a stranger appeared next to real conversations. The dedicated Marmot group list screen split by ownerSentMessage only, so unreplied groups where an admin was followed still showed as New Requests. Replace both splits with a shared MarmotGroupChatroom.isKnown(followingKeySet): - user has replied -> Known - no known members and no messages -> New Requests - otherwise Known iff any admin is in the follow set Wire the combined Messages feeds (ChatroomListKnownFeedFilter and ChatroomListNewFeedFilter) through the helper, invalidate dmNew on group list changes so empty groups flow into New Requests, and have MarmotGroupListScreen observe kind3FollowList so newly followed admins move groups between tabs immediately. --- .../screen/loggedIn/AccountFeedContentStates.kt | 1 + .../chats/marmotGroup/MarmotGroupListScreen.kt | 8 ++++++-- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 6 +++++- .../chats/rooms/dal/ChatroomListNewFeedFilter.kt | 11 ++++++++++- .../model/marmotGroups/MarmotGroupChatroom.kt | 15 +++++++++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 5c52c7b27..fb08a3e52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -124,6 +124,7 @@ class AccountFeedContentStates( scope.launch(Dispatchers.IO) { account.marmotGroupList.groupListChanges.collect { dmKnown.invalidateData() + dmNew.invalidateData() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index 6154ade01..cbcd0d9ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -93,8 +93,12 @@ fun MarmotGroupListScreen( // (Account.ensureMarmotKeyPackagePublished), so this screen no longer // needs to do anything to make sure invitees can find a KeyPackage. - val knownGroups = remember(groupList) { groupList.filter { it.second.ownerSentMessage } } - val newRequestGroups = remember(groupList) { groupList.filter { !it.second.ownerSentMessage } } + val followState by accountViewModel.account.kind3FollowList.flow + .collectAsStateWithLifecycle() + val followingKeySet = followState.authors + + val knownGroups = remember(groupList, followingKeySet) { groupList.filter { it.second.isKnown(followingKeySet) } } + val newRequestGroups = remember(groupList, followingKeySet) { groupList.filter { !it.second.isKnown(followingKeySet) } } val visibleGroups = if (selectedTab == 0) knownGroups else newRequestGroups Scaffold( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index e826250c2..59645b592 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -79,7 +79,11 @@ class ChatroomListKnownFeedFilter( val marmotGroups = account.marmotGroupList.rooms.mapNotNull { _, chatroom -> - chatroom.newestMessage ?: chatroom.placeholderNote() + if (chatroom.isKnown(followingKeySet)) { + chatroom.newestMessage ?: chatroom.placeholderNote() + } else { + null + } } return (privateMessages + publicChannels + ephemeralChats + marmotGroups).sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 27ac9854e..9f859dd14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -47,7 +47,16 @@ class ChatroomListNewFeedFilter( } } - return privateMessages.sortedWith(DefaultFeedOrder) + val marmotGroups = + account.marmotGroupList.rooms.mapNotNull { _, chatroom -> + if (!chatroom.isKnown(followingKeySet)) { + chatroom.newestMessage ?: chatroom.placeholderNote() + } else { + null + } + } + + return (privateMessages + marmotGroups).sortedWith(DefaultFeedOrder) } override fun updateListWith( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index 85a857f7f..cd106aabd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -69,6 +69,21 @@ class MarmotGroupChatroom( */ var ownerSentMessage: Boolean = false + /** + * Classifies this group for the Known/New Requests split. + * + * Rules: + * - If the local user has already participated ([ownerSentMessage]), Known. + * - If the group has no known members and no messages yet, New Requests + * (we don't know who invited us, so it's untrusted by default). + * - Otherwise, Known iff at least one admin is in the follow set. + */ + fun isKnown(followingKeySet: Set): Boolean { + if (ownerSentMessage) return true + if (memberCount.value == 0 && messages.isEmpty()) return false + return adminPubkeys.value.any { it in followingKeySet } + } + // Synthetic note used by list views to represent the group when no // messages have been received yet. Lazily created and kept stable so // equality-based feed diffing treats it as the same row across refreshes. From f6f2a34353cbe518ffd01b8d6eb3aa3fe32244bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 21:45:14 +0000 Subject: [PATCH 2/5] fix(cli): route Marmot welcome + KeyPackage fetch to the invitee's relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `amy marmot group add` previously sent the Welcome gift wrap to the inviter's own kind:10050 (ctx.inboxRelays()) and fetched the invitee's KeyPackage from only the inviter's configured relays. Two users with disjoint relay configurations could never successfully marmot each other — the welcome landed somewhere the invitee never polled. Mirror the Android Account.addMarmotGroupMember flow in the CLI: - New RecipientRelayFetcher in quartz/marmot one-shot-drains a target user's kind:10050, kind:10051 and kind:10002 from a seed relay set. Replaceable-event semantics: newest created_at per kind wins. Guards against relays echoing events authored by someone else. - Context.bootstrapRelays() unions the inviter's configured relays with AmethystDefaults (DefaultNIP65RelaySet + DefaultDMRelayList) so we can discover strangers even when nothing overlaps with our own config. - GroupAddMemberCommand now per-invitee: looks up their relay lists; passes kind:10051 + kind:10002 write + bootstrap to KeyPackageFetcher; publishes the welcome to kind:10050 (fallback to NIP-65 read, then DefaultDMRelayList, then our outbox as belt-and-braces). Group commits/messages (kind:445) continue to use the group's MIP-01 relay set — those were already correct. Exposes welcome_targets and key_package_relays in the JSON output so callers can verify routing. --- .../com/vitorpamplona/amethyst/cli/Context.kt | 18 +++ .../cli/commands/GroupAddMemberCommand.kt | 82 +++++++++-- .../quartz/marmot/RecipientRelayFetcher.kt | 134 ++++++++++++++++++ 3 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index e2540ebbc..24e6c9dd0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -125,6 +125,24 @@ class Context( fun anyRelays(): Set = relays.normalized("all") + /** + * Seed relays for "look up someone we know nothing about" queries — + * fetching another user's kind:10002 / 10050 / 10051 / 30443 before we + * can deliver something to them. + * + * Strategy: union our own configured relays with Amethyst's hard-coded + * defaults (DefaultNIP65RelaySet + DefaultDMRelayList). The defaults are + * what every fresh Amethyst account publishes to first, so they're the + * most reliable place to find a stranger's replaceable events even when + * we and they have completely disjoint relay configurations. + */ + fun bootstrapRelays(): Set = + buildSet { + addAll(anyRelays()) + addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet) + addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList) + } + /** * Publish an event to the given relays and wait for OK confirmations. * diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt index 89e1650a6..effd7c9c7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -23,13 +23,20 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher /** * `group add [ ...]` — fetch each invitee's - * KeyPackage from the union of (our relays + any known KeyPackage relays - * for them) and run the full add-member flow for each one: build commit, - * publish commit to the group's relays, then wrap + publish the Welcome - * gift wrap. + * KeyPackage from the union of (their advertised KeyPackage relays + + * their NIP-65 outbox + our bootstrap relays) and run the full add-member + * flow for each one: build commit, publish commit to the group's relays, + * then wrap + publish the Welcome gift wrap to the invitee's DM inbox + * (kind:10050) with their NIP-65 read relays as a fallback. + * + * Two different users could have completely disjoint relay configurations + * and still successfully marmot each other — we discover where each + * invitee is actually listening before routing anything to them. */ object GroupAddMemberCommand { suspend fun run( @@ -50,15 +57,42 @@ object GroupAddMemberCommand { val invitees = rest.drop(1).map { ctx.requireUserHex(it) } val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + // Computed once: the seed relays we query for any stranger's + // published relay-routing events. Union of our own configured + // relays and Amethyst's hard-coded defaults so we stay useful + // when an invitee shares nothing with us but used Amethyst to + // bootstrap. + val seed = ctx.bootstrapRelays() val report = mutableListOf>() for (pub in invitees) { - val relays = - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher - .fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays()) + // Discover where *this* invitee actually reads from. Without + // this the inviter can only broadcast to their own relays, + // which silently fails the moment the two users have + // disjoint relay configs. + val recipient = + RecipientRelayFetcher.fetchRelayLists( + client = ctx.client, + pubKey = pub, + seedRelays = seed, + ) + + // KeyPackage discovery (MIP-00): prefer the invitee's own + // kind:10051, then their kind:10002 write marker, then our + // bootstrap pool as a last-resort fallback. + val kpRelays = + KeyPackageFetcher.fetchRelaysFor( + targetKeyPackageRelays = recipient.keyPackage, + targetOutbox = recipient.nip65Write(), + myOutbox = seed, + ) val kpEvent = - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher - .fetchKeyPackage(ctx.client, pub, relays, timeoutMs = 10_000) + KeyPackageFetcher.fetchKeyPackage( + client = ctx.client, + targetPubKey = pub, + relays = kpRelays, + timeoutMs = 10_000, + ) if (kpEvent == null) { report.add(mapOf("pubkey" to pub, "status" to "no_key_package")) continue @@ -74,10 +108,32 @@ object GroupAddMemberCommand { // Order matters: commit first (so invitee doesn't join at a future epoch), // then welcome. val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays) - val welcomeAck = + val welcomeTargets: Set = if (welcomeDelivery != null) { - val inbox = ctx.inboxRelays().ifEmpty { ctx.outboxRelays() } - ctx.publish(welcomeDelivery.giftWrapEvent, inbox) + // Welcome gift wrap (kind:1059 wrapping kind:444) must + // land on a relay the invitee actually polls for their + // NIP-59 inbox. Priority: + // 1. kind:10050 (their explicit DM inbox) + // 2. kind:10002 read markers (NIP-65 fallback, + // matches User.dmInboxRelays()) + // 3. Amethyst's DefaultDMRelayList (best-effort if + // the invitee has published nothing — freshly- + // bootstrapped Amethyst accounts listen on these) + // Our own outbox is added as belt-and-braces so we + // can re-ingest the welcome ourselves too. + buildSet { + addAll(recipient.dmInboxOrFallback()) + if (isEmpty()) { + addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList) + } + addAll(ctx.outboxRelays()) + } + } else { + emptySet() + } + val welcomeAck = + if (welcomeDelivery != null && welcomeTargets.isNotEmpty()) { + ctx.publish(welcomeDelivery.giftWrapEvent, welcomeTargets) } else { emptyMap() } @@ -91,6 +147,8 @@ object GroupAddMemberCommand { "welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id, "commit_accepted_by" to commitAck.filterValues { it }.keys.map { it.url }, "welcome_accepted_by" to welcomeAck.filterValues { it }.keys.map { it.url }, + "welcome_targets" to welcomeTargets.map { it.url }, + "key_package_relays" to kpRelays.map { it.url }, ), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt new file mode 100644 index 000000000..9014af621 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt @@ -0,0 +1,134 @@ +/* + * 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.quartz.marmot + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * One-shot lookup of a user's published relay-routing events. Used by stateless + * callers (the `amy` CLI, automated agents) that don't keep a long-lived + * `LocalCache` around to subscribe to these events as they arrive. + * + * The Amethyst Android app does the equivalent via `cache.getOrCreateUser(pk) + * .dmInboxRelays()` / `.outboxRelays()` after its background subscriptions + * have populated the cache. The CLI doesn't have that pipeline, so it has to + * pull the same three replaceable events on demand whenever it needs to + * deliver something to someone whose relays it doesn't already know. + * + * Specifically required for Marmot (MIP-00..03): + * - kind:10050 — where to deliver the NIP-59 Welcome gift wrap. + * - kind:10051 — where to read MIP-00 KeyPackages from. + * - kind:10002 — fallback for both, plus a generic outbox/inbox advertisement. + */ +object RecipientRelayFetcher { + data class Lists( + /** kind:10050 relays — where the user's NIP-17 / NIP-59 inbox lives. */ + val dmInbox: List, + /** kind:10051 relays — where the user hosts MIP-00 KeyPackages. */ + val keyPackage: List, + /** Latest kind:10002 the user published (null if none seen). */ + val nip65: AdvertisedRelayListEvent?, + ) { + /** Read-marker relays from kind:10002. Mirrors `User.inboxRelays()`. */ + fun nip65Read(): List = nip65?.readRelaysNorm().orEmpty() + + /** Write-marker relays from kind:10002. Mirrors `User.outboxRelays()`. */ + fun nip65Write(): List = nip65?.writeRelaysNorm().orEmpty() + + /** + * Where to deliver a NIP-59 gift wrap addressed to this user. + * Mirrors `User.dmInboxRelays()`: prefer kind:10050, fall back to the + * NIP-65 read marker. + */ + fun dmInboxOrFallback(): List = dmInbox.ifEmpty { nip65Read() } + } + + /** + * Drain the latest kind:10050, 10051, 10002 events for [pubKey] from + * [seedRelays] and return them grouped by kind. Replaceable-event + * semantics: when a relay returns multiple versions, the newest + * `created_at` wins. + * + * Returns empty lists if no relays were given or no events arrived in + * time — callers decide how to fall back. + */ + suspend fun fetchRelayLists( + client: INostrClient, + pubKey: HexKey, + seedRelays: Set, + timeoutMs: Long = 8_000L, + ): Lists { + if (seedRelays.isEmpty()) return Lists(emptyList(), emptyList(), null) + + val filter = + Filter( + kinds = + listOf( + ChatMessageRelayListEvent.KIND, + KeyPackageRelayListEvent.KIND, + AdvertisedRelayListEvent.KIND, + ), + authors = listOf(pubKey), + ) + + val events = + client.fetchAll( + filters = seedRelays.associateWith { listOf(filter) }, + timeoutMs = timeoutMs, + ) + + var dm: ChatMessageRelayListEvent? = null + var kp: KeyPackageRelayListEvent? = null + var nip65: AdvertisedRelayListEvent? = null + for (event in events) { + // Authors filter is enforced relay-side, but a malicious or buggy + // relay could echo something else. Keep the guard so we never + // route someone else's wrapped welcome to the wrong inbox. + if (event.pubKey != pubKey) continue + when (event) { + is ChatMessageRelayListEvent -> { + if (dm == null || event.createdAt > dm.createdAt) dm = event + } + + is KeyPackageRelayListEvent -> { + if (kp == null || event.createdAt > kp.createdAt) kp = event + } + + is AdvertisedRelayListEvent -> { + if (nip65 == null || event.createdAt > nip65.createdAt) nip65 = event + } + } + } + + return Lists( + dmInbox = dm?.relays().orEmpty(), + keyPackage = kp?.relays().orEmpty(), + nip65 = nip65, + ) + } +} From 5289fa3398da91e35f2e176ebf41eec7cca12752 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 22:04:30 +0000 Subject: [PATCH 3/5] style(cli): import NormalizedRelayUrl + Amethyst defaults instead of inlining FQNs --- .../main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt | 6 ++++-- .../amethyst/cli/commands/GroupAddMemberCommand.kt | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 24e6c9dd0..b08b3236a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.cli import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore +import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.marmot.ingest import com.vitorpamplona.quartz.marmot.MarmotFilters @@ -139,8 +141,8 @@ class Context( fun bootstrapRelays(): Set = buildSet { addAll(anyRelays()) - addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet) - addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList) + addAll(DefaultNIP65RelaySet) + addAll(DefaultDMRelayList) } /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt index effd7c9c7..ab18c5556 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * `group add [ ...]` — fetch each invitee's @@ -108,7 +110,7 @@ object GroupAddMemberCommand { // Order matters: commit first (so invitee doesn't join at a future epoch), // then welcome. val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays) - val welcomeTargets: Set = + val welcomeTargets: Set = if (welcomeDelivery != null) { // Welcome gift wrap (kind:1059 wrapping kind:444) must // land on a relay the invitee actually polls for their @@ -124,7 +126,7 @@ object GroupAddMemberCommand { buildSet { addAll(recipient.dmInboxOrFallback()) if (isEmpty()) { - addAll(com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList) + addAll(DefaultDMRelayList) } addAll(ctx.outboxRelays()) } From c4e7e0d9b46f517c1b672293d10a666fce872a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 22:07:15 +0000 Subject: [PATCH 4/5] fix: don't render Marmot reactions/deletions as chat bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhiteNoise threads its kind:445 payloads across three inner kinds: kind:9 for chat, kind:7 for emoji reactions, kind:5 for unreacts. The Amethyst ingest pipeline was routing every inner event into the group chatroom feed, so a kind:7 reaction rendered as a chat bubble whose only content was the emoji, quoting the liked message via the target `e` tag. That looked identical to a threaded reply. The actual kind:9 reply from `wn messages send --reply-to` never arrived, which made the two look swapped in the UI. Three changes to untangle this: - MarmotGroupList.addMessage/restoreMessage now skip inner events with kind:5 and kind:7 before they enter `MarmotGroupChatroom.messages`. The reaction is still consumed by LocalCache so it attaches to the target note's reaction row, and the deletion still revokes that reaction — they just don't appear as standalone bubbles. - LocalCache.computeReplyTo learned to derive thread parents for `ChatEvent` (kind:9) from plain NIP-10 `e` tags in addition to the existing NIP-18 `q` tag path. WhiteNoise emits `e`-tagged replies; without this the reply bubble had no quote context in the feed. - tools/marmot-interop/marmot-interop.sh: `wn messages send` exposes its `reply_to` field through clap v4, which renames snake_case to kebab-case by default. The script was passing `--reply_to`, which clap rejected; the `|| true` + redirected stderr hid the error and no reply was ever published. Use `--reply-to`. https://claude.ai/code/session_01K3g1uWLhByoEdBS77zdF32 --- .../amethyst/model/LocalCache.kt | 14 +++++++++++ .../model/marmotGroups/MarmotGroupList.kt | 25 +++++++++++++++++++ tools/marmot-interop/marmot-interop.sh | 6 ++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ac8d998f3..a6af8229c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -914,6 +914,20 @@ object LocalCache : ILocalCache, ICacheProvider { event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } } + is ChatEvent -> { + // Amethyst's own kind:9 replies carry the parent as a NIP-18 + // `q` tag (see ChatEvent.replyingTo), but the broader Marmot + // ecosystem — WhiteNoise in particular — threads kind:9 chats + // with a plain NIP-10 `e` tag. Accept both so inbound replies + // from either client show their quote bubble in the feed. + val eTagTargets = + event.tags + .filter { it.size > 1 && it[0] == "e" } + .map { it[1] } + val qTagTargets = event.quotedEvents().map { it.eventId } + (eTagTargets + qTagTargets).mapNotNull { checkGetOrCreateNote(it) } + } + else -> { emptyList() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index db34fd791..5e622ead4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -47,6 +47,7 @@ class MarmotGroupList( nostrGroupId: HexKey, msg: Note, ) { + if (!isDisplayableFeedMessage(msg)) return val chatroom = getOrCreateGroup(nostrGroupId) val isSelfAuthored = msg.author?.pubkeyHex == ownerPubKey // Use the quiet path for our own messages so the relay round-trip @@ -74,6 +75,7 @@ class MarmotGroupList( nostrGroupId: HexKey, msg: Note, ) { + if (!isDisplayableFeedMessage(msg)) return val chatroom = getOrCreateGroup(nostrGroupId) if (chatroom.restoreMessageSync(msg)) { noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId } @@ -135,4 +137,27 @@ class MarmotGroupList( rooms.forEach { key, _ -> result.add(key) } return result } + + /** + * True if this inner event should appear as its own bubble in the group + * chat feed. Side-channel kinds (reactions, deletions) must still be + * consumed into LocalCache — they drive the reaction row on the target + * note and, for kind:5, revoke a prior reaction — but they must NOT show + * up as standalone messages. + * + * Needed because WhiteNoise emits plain kind:7 reactions (emoji content + + * `e` tag) and kind:5 unreacts inside kind:445, and the Marmot pipeline + * blindly routed every inner event into the chatroom. The reaction then + * rendered as a chat bubble containing just the emoji, with a quoted + * citation of the target message — which reads exactly like a reply. + */ + private fun isDisplayableFeedMessage(msg: Note): Boolean { + val kind = msg.event?.kind ?: return true + return kind != MARMOT_INNER_KIND_REACTION && kind != MARMOT_INNER_KIND_DELETION + } + + companion object { + private const val MARMOT_INNER_KIND_DELETION = 5 + private const val MARMOT_INNER_KIND_REACTION = 7 + } } diff --git a/tools/marmot-interop/marmot-interop.sh b/tools/marmot-interop/marmot-interop.sh index 876572cd6..4674fbe0c 100755 --- a/tools/marmot-interop/marmot-interop.sh +++ b/tools/marmot-interop/marmot-interop.sh @@ -776,7 +776,11 @@ test_09_reply_react_unreact() { fi step "B replies to the anchor" - wn_b messages send "$gid" "replying via wn" --reply_to "$msg_id" >/dev/null 2>&1 || true + # clap v4 converts snake_case fields to kebab-case flags by default, so the + # `reply_to: Option` field on `wn messages send` exposes as + # `--reply-to`. Passing `--reply_to` is silently rejected (the script's + # `|| true` hides the error) and the reply is never actually published. + wn_b messages send "$gid" "replying via wn" --reply-to "$msg_id" >/dev/null 2>&1 || true sleep 3 if confirm "Does Amethyst show 'replying via wn' as a threaded reply to the anchor?"; then record_result "09 reply/react" pass From 0657f6e1c7b904d62d3a0adbc0cb318e8e5ed0ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 22:15:10 +0000 Subject: [PATCH 5/5] fix(cli): apply Marmot relay-routing rules across every marmot command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass only fixed `group add`. Auditing the rest of the CLI against MIP-00..03 turned up three more spots where `amy` either queried the wrong relays or silently skipped a required advertisement: 1. `marmot key-package check ` used `anyRelays()` — i.e. the inviter's configured relays — so checking for a KeyPackage on a user who advertises `kind:10051` somewhere we don't know about always returned `not_found`. Now runs RecipientRelayFetcher against bootstrap seeds and fetches from the union of (target kind:10051, target kind:10002 write, bootstrap). Emits `found_on` so callers can see which relay served the hit. 2. `await key-package ` had the same bug inside the poll loop. Resolved once up front, then the loop fetches from the target's advertised relays every tick. Throws an `AwaitTimeout` early if no relays can be discovered at all, instead of silently polling void. 3. `relay publish-lists` published kind:10002 + kind:10050 but never kind:10051. Per MIP-00 the KeyPackage Relay List is how other Marmot clients discover where our KPs live — without it the `key_package` bucket on disk is invisible to anyone else. Now also publishes kind:10051; falls back to the NIP-65 set if the bucket is empty so we never advertise an empty list. (`amy create` already publishes it via AccountBootstrapEvents.) Docs: cli/README adds a "Relay routing" section that lists the exact relay set used for publish vs fetch of every Marmot event kind, plus the bootstrap-pool definition, so agents + interop-test authors can reason about cross-user reachability without reading the code. --- cli/README.md | 35 +++++++++++++-- .../amethyst/cli/commands/AwaitCommands.kt | 44 ++++++++++++++----- .../cli/commands/KeyPackageCommands.kt | 28 +++++++++--- .../amethyst/cli/commands/RelayCommands.kt | 15 +++++++ 4 files changed, 100 insertions(+), 22 deletions(-) diff --git a/cli/README.md b/cli/README.md index 6304b15d9..036071d29 100644 --- a/cli/README.md +++ b/cli/README.md @@ -114,9 +114,9 @@ Run `amy --help` for the canonical list. As of today: | `whoami` | Print the identity stored in `--data-dir`. | | `relay add URL [--type T]` | `T = nip65 \| inbox \| key_package \| all`. | | `relay list` | Dump configured relays by bucket. | -| `relay publish-lists` | Publish kind:10002 (NIP-65) + kind:10050 (DM inbox). | -| `marmot key-package publish` | Publish a fresh MLS KeyPackage (kind:30443). | -| `marmot key-package check NPUB` | Fetch someone else's KeyPackage from their advertised relays. | +| `relay publish-lists` | Publish kind:10002 (NIP-65) + kind:10050 (DM inbox) + kind:10051 (KeyPackage relay list). | +| `marmot key-package publish` | Publish a fresh MLS KeyPackage (kind:30443) to the configured `key_package` bucket (fallback: NIP-65 outbox). | +| `marmot key-package check NPUB` | Look up NPUB's kind:10051 / kind:10002 on bootstrap relays, then fetch their KeyPackage from those relays. | | `marmot group create [--name NAME]` | New empty group with you as sole admin. | | `marmot group list` | All groups you're a member of. | | `marmot group show GID` | Full group state (members, admins, epoch, metadata). | @@ -130,7 +130,7 @@ Run `amy --help` for the canonical list. As of today: | `marmot group leave GID` | Self-remove. | | `marmot message send GID TEXT` | Publish a kind:9 inner event into the group. | | `marmot message list GID [--limit N]` | Decrypted inner events, oldest first. | -| `marmot await key-package NPUB` | Block until a KeyPackage is seen on relays. | +| `marmot await key-package NPUB` | Block until a KeyPackage is seen on NPUB's advertised relays (kind:10051 / kind:10002). | | `marmot await group --name NAME` | Block until we're added to a group with that name. | | `marmot await member GID NPUB` | Block until NPUB is in GID's member set. | | `marmot await admin GID NPUB` | Block until NPUB is an admin of GID. | @@ -150,6 +150,33 @@ itself crashed". --- +## Relay routing + +Amy follows the Marmot protocol's per-event routing rules so two users +with completely disjoint relay configurations can still marmot each +other. No event ever ships blindly to "our configured relays" — Amy +looks up the right relay set per event per recipient. + +| Event | Publish to | Fetch from | +|---|---|---| +| kind:30443 (our own KeyPackage) | `key_package` bucket → NIP-65 outbox → any configured | — | +| kind:30443 (someone else's KeyPackage) | — | Their kind:10051 → their kind:10002 write → our bootstrap pool | +| kind:10051 / 10050 / 10002 (our own lists) | All configured relays (broadcast) | — | +| kind:10051 / 10050 / 10002 (someone else's) | — | Our bootstrap pool = configured relays ∪ Amethyst defaults | +| kind:1059 Welcome gift wrap (kind:444 inside) | Recipient's kind:10050 → their kind:10002 read → `DefaultDMRelayList` → our outbox | — | +| kind:1059 gift wraps addressed to us | — | Our kind:10050 | +| kind:445 Group Event (Commit / Proposal / chat) | Group's MIP-01 `relays` field | Same | + +**Bootstrap pool**: when Amy needs to discover a user it's never talked +to, it queries `configured relays ∪ Amethyst's default NIP-65 set ∪ +Amethyst's default DM-inbox set`. These defaults come from +`commons.defaults.AmethystDefaults` and match what the Android/Desktop +UI publishes to on first run, so any fresh Amethyst account is +reachable via the bootstrap pool even before Amy has seen any of their +events. + +--- + ## Data-dir layout ``` diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt index a741a536b..dec64ae71 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -26,7 +26,9 @@ import com.vitorpamplona.amethyst.cli.AwaitTimeout import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import kotlinx.coroutines.delay @@ -65,19 +67,39 @@ object AwaitCommands { ctx.prepare() val target = ctx.requireUserHex(rest[0]) val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target) + // MIP-00: target's KeyPackages live on the relays advertised in + // their kind:10051 (fallback: kind:10002 write). Resolve the + // right relay set once up front against bootstrap seeds; the + // polling loop then hits those relays on every tick. If the + // target hasn't published either list yet, fall back to the + // bootstrap pool so the loop still has something to query. + val seed = ctx.bootstrapRelays() + val lists = RecipientRelayFetcher.fetchRelayLists(ctx.client, target, seed) + val relays = + KeyPackageFetcher.fetchRelaysFor( + targetKeyPackageRelays = lists.keyPackage, + targetOutbox = lists.nip65Write(), + myOutbox = seed, + ) + if (relays.isEmpty()) { + throw AwaitTimeout("no relays to query for $target (configure relays or bootstrap defaults first)") + } val deadline = System.currentTimeMillis() + timeoutSecs * 1000 while (System.currentTimeMillis() < deadline) { - val relays = ctx.anyRelays() - if (relays.isNotEmpty()) { - val event = - ctx.client.fetchFirst( - filters = relays.associateWith { listOf(filter) }, - timeoutMs = 3_000, - ) - if (event is KeyPackageEvent) { - Json.writeLine(mapOf("event_id" to event.id, "author" to event.pubKey)) - return 0 - } + val event = + ctx.client.fetchFirst( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = 3_000, + ) + if (event is KeyPackageEvent) { + Json.writeLine( + mapOf( + "event_id" to event.id, + "author" to event.pubKey, + "found_on" to relays.map { it.url }, + ), + ) + return 0 } delay(2_000) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt index 3f647a4d2..91129df27 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher object KeyPackageCommands { suspend fun dispatch( @@ -69,15 +71,26 @@ object KeyPackageCommands { try { ctx.prepare() val targetHex = ctx.requireUserHex(rest[0]) - // CLI doesn't (yet) cache target's kind:10051/10002 — just ask every - // configured relay. Amethyst, which does cache those, passes them in. + // Per MIP-00: a user's KeyPackages live on the relays advertised + // in their kind:10051 event (fallback: kind:10002 write marker). + // Look those up first from bootstrap seeds so `check` works even + // when the target and inviter share no relays. + val seed = ctx.bootstrapRelays() + if (seed.isEmpty()) return Json.error("no_relays", "configure relays first") + val recipient = RecipientRelayFetcher.fetchRelayLists(ctx.client, targetHex, seed) val relays = - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher - .fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays()) - if (relays.isEmpty()) return Json.error("no_relays", "configure relays first") + KeyPackageFetcher.fetchRelaysFor( + targetKeyPackageRelays = recipient.keyPackage, + targetOutbox = recipient.nip65Write(), + myOutbox = seed, + ) val event = - com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher - .fetchKeyPackage(ctx.client, targetHex, relays, timeoutMs = 10_000) + KeyPackageFetcher.fetchKeyPackage( + client = ctx.client, + targetPubKey = targetHex, + relays = relays, + timeoutMs = 10_000, + ) if (event == null) { return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)") } @@ -88,6 +101,7 @@ object KeyPackageCommands { "kind" to event.kind, "created_at" to event.createdAt, "has_content" to event.content.isNotBlank(), + "found_on" to relays.map { it.url }, ), ) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 7a34df1c0..1cbe0ff56 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo @@ -86,23 +87,37 @@ object RelayCommands { ctx.prepare() val nip65Relays = ctx.relays.normalized("nip65").toList() val inboxRelays = ctx.relays.normalized("inbox").toList() + // MIP-00: other clients discover our KeyPackages by querying the + // relays advertised in our kind:10051 event. If no key_package + // bucket is configured, fall back to the NIP-65 set so we always + // publish a non-empty list — an empty 10051 would make us + // undiscoverable by other Marmot clients. + val keyPackageRelays = + ctx.relays + .normalized("key_package") + .ifEmpty { ctx.outboxRelays() } + .toList() val nip65Infos = nip65Relays.map { AdvertisedRelayInfo(it, AdvertisedRelayType.BOTH) } val nip65Event = AdvertisedRelayListEvent.create(nip65Infos, ctx.signer) val inboxEvent = ChatMessageRelayListEvent.create(inboxRelays, ctx.signer) + val keyPackageListEvent = KeyPackageRelayListEvent.create(keyPackageRelays, ctx.signer) val targets = ctx.anyRelays() val nip65Result = ctx.publish(nip65Event, targets) val inboxResult = ctx.publish(inboxEvent, targets) + val keyPackageListResult = ctx.publish(keyPackageListEvent, targets) Json.writeLine( mapOf( "nip65_event_id" to nip65Event.id, "inbox_event_id" to inboxEvent.id, + "key_package_list_event_id" to keyPackageListEvent.id, "accepted_by" to mapOf( "nip65" to nip65Result.filterValues { it }.keys.map { it.url }, "inbox" to inboxResult.filterValues { it }.keys.map { it.url }, + "key_package_list" to keyPackageListResult.filterValues { it }.keys.map { it.url }, ), ), )