Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
+1
@@ -124,6 +124,7 @@ class AccountFeedContentStates(
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.marmotGroupList.groupListChanges.collect {
|
||||
dmKnown.invalidateData()
|
||||
dmNew.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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(
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+10
-1
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
+72
-12
@@ -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 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
+15
@@ -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.
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user