Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs

This commit is contained in:
Claude
2026-04-22 23:41:40 +00:00
15 changed files with 407 additions and 39 deletions
@@ -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()
}
@@ -124,6 +124,7 @@ class AccountFeedContentStates(
scope.launch(Dispatchers.IO) {
account.marmotGroupList.groupListChanges.collect {
dmKnown.invalidateData()
dmNew.invalidateData()
}
}
}
@@ -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(
@@ -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)
@@ -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(
+31 -4
View File
@@ -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
```
@@ -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
@@ -125,6 +127,24 @@ class Context(
fun anyRelays(): Set<NormalizedRelayUrl> = 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<NormalizedRelayUrl> =
buildSet {
addAll(anyRelays())
addAll(DefaultNIP65RelaySet)
addAll(DefaultDMRelayList)
}
/**
* Publish an event to the given relays and wait for OK confirmations.
*
@@ -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)
}
@@ -23,13 +23,22 @@ 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 <group_id> <npub> [<npub> ...]` 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 +59,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<Map<String, Any?>>()
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 +110,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<NormalizedRelayUrl> =
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(DefaultDMRelayList)
}
addAll(ctx.outboxRelays())
}
} else {
emptySet()
}
val welcomeAck =
if (welcomeDelivery != null && welcomeTargets.isNotEmpty()) {
ctx.publish(welcomeDelivery.giftWrapEvent, welcomeTargets)
} else {
emptyMap()
}
@@ -91,6 +149,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 },
),
)
}
@@ -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
@@ -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 },
),
),
)
@@ -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<HexKey>): 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.
@@ -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
}
}
@@ -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<NormalizedRelayUrl>,
/** kind:10051 relays — where the user hosts MIP-00 KeyPackages. */
val keyPackage: List<NormalizedRelayUrl>,
/** 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<NormalizedRelayUrl> = nip65?.readRelaysNorm().orEmpty()
/** Write-marker relays from kind:10002. Mirrors `User.outboxRelays()`. */
fun nip65Write(): List<NormalizedRelayUrl> = 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<NormalizedRelayUrl> = 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<NormalizedRelayUrl>,
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,
)
}
}
+5 -1
View File
@@ -1061,7 +1061,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<String>` 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