refactor(marmot,cli): lift shared logic into quartz/commons
Five extractions so the Amethyst UI and the new amy CLI share one implementation for each piece of Marmot/identity behaviour instead of reimplementing it per platform. quartz additions: - MarmotGroupData.bootstrap(..) + withMergedRelays(..): factory for the metadata shape every inviter stamps on a fresh group and the outbox- merge rule every admin commit carries forward. - KeyPackageFetcher: (a) pure fetchRelaysFor union of target kind:10051, target outbox, my outbox; (b) one-shot fetchKeyPackage; (c) publish- side resolveKeyPackagePublishRelays. - resolveUserHexOrNull: single entry point for npub / nprofile / 64-hex / NIP-05 identifiers. Uses the existing Nip19Parser + Nip05Client infra so NIP-05 resolution is live over HTTP. commons additions: - MarmotManager.leafIndexOf(..): pubkey -> leaf index lookup used by every removeMember caller. - MarmotManager.addMember(nostrGroupId, keyPackageEvent, relays): convenience overload that lifts the base64 decode into the manager. - MarmotManager.buildTextMessage(..) returning a TextMessageBundle; optionally persists the outbound inner event (CLI needs persist, Amethyst relies on LocalCache loopback). - MarmotIngest.ingest(event): routes kind:1059 / kind:445 through unwrap -> processWelcome / processGroupEvent -> persist the decrypted application message. Deliberately platform-agnostic. Retrofits: - Account.addMarmotGroupMember and fetchKeyPackageAndAddMember now take a KeyPackageEvent directly; Account.keyPackagePublishRelays delegates to KeyPackageFetcher. - AccountViewModel.updateMarmotGroupMetadata uses bootstrap + merged. - AccountViewModel.sendMarmotGroupMessage builds the kind:9 template via buildTextMessage(persistOwn = false) to avoid double-persisting. - AccountSessionManager's NIP-05 login branch delegates to resolveUserHexOrNull; no more hand-rolled Nip05Id / nip05Client.get. - CLI Context exposes nip05Client and requireUserHex; syncIncoming now calls MarmotManager.ingest; all command sites use KeyPackageFetcher + the shared identifier resolver. Npubs util deleted. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
This commit is contained in:
@@ -1995,9 +1995,6 @@ class Account(
|
|||||||
val manager = marmotManager ?: return "Error: Marmot not initialized"
|
val manager = marmotManager ?: return "Error: Marmot not initialized"
|
||||||
if (!isWriteable()) return "Error: Account is read-only"
|
if (!isWriteable()) return "Error: Account is read-only"
|
||||||
|
|
||||||
// Build filter for the member's KeyPackages
|
|
||||||
val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey)
|
|
||||||
|
|
||||||
// Per MIP-00, invitees advertise the relays that host their
|
// Per MIP-00, invitees advertise the relays that host their
|
||||||
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
|
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
|
||||||
// there first, then fall back to the invitee's NIP-65 outbox
|
// there first, then fall back to the invitee's NIP-65 outbox
|
||||||
@@ -2019,20 +2016,18 @@ class Account(
|
|||||||
.outboxRelays()
|
.outboxRelays()
|
||||||
?.toSet()
|
?.toSet()
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
val fetchRelays = memberKeyPackageRelays + memberOutbox + myOutbox
|
val fetchRelays =
|
||||||
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||||
|
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
|
||||||
|
|
||||||
Log.d("MarmotDbg") {
|
Log.d("MarmotDbg") {
|
||||||
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
|
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
|
||||||
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
|
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query across the combined relay set
|
|
||||||
val filterMap = fetchRelays.associateWith { listOf(filter) }
|
|
||||||
|
|
||||||
val event =
|
val event =
|
||||||
client.fetchFirst(
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||||
filters = filterMap,
|
.fetchKeyPackage(client, memberPubKey, fetchRelays)
|
||||||
)
|
|
||||||
|
|
||||||
if (event == null) {
|
if (event == null) {
|
||||||
Log.w("MarmotDbg") {
|
Log.w("MarmotDbg") {
|
||||||
@@ -2045,21 +2040,12 @@ class Account(
|
|||||||
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…"
|
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event !is com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent) {
|
|
||||||
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: unexpected kind ${event.kind}" }
|
|
||||||
return "Error: Unexpected event type received"
|
|
||||||
}
|
|
||||||
|
|
||||||
val keyPackageBase64 = event.keyPackageBase64()
|
val keyPackageBase64 = event.keyPackageBase64()
|
||||||
if (keyPackageBase64.isBlank()) {
|
if (keyPackageBase64.isBlank()) {
|
||||||
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
|
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
|
||||||
return "Error: KeyPackage event has empty content"
|
return "Error: KeyPackage event has empty content"
|
||||||
}
|
}
|
||||||
|
|
||||||
val keyPackageBytes =
|
|
||||||
kotlin.io.encoding.Base64
|
|
||||||
.decode(keyPackageBase64)
|
|
||||||
val keyPackageEventId = event.id
|
|
||||||
// The relays embedded in the WelcomeEvent tell the new member
|
// The relays embedded in the WelcomeEvent tell the new member
|
||||||
// where to subscribe for subsequent GroupEvents. Use our own
|
// where to subscribe for subsequent GroupEvents. Use our own
|
||||||
// outbox — that's where we will publish them.
|
// outbox — that's where we will publish them.
|
||||||
@@ -2071,9 +2057,7 @@ class Account(
|
|||||||
|
|
||||||
addMarmotGroupMember(
|
addMarmotGroupMember(
|
||||||
nostrGroupId = nostrGroupId,
|
nostrGroupId = nostrGroupId,
|
||||||
memberPubKey = memberPubKey,
|
keyPackageEvent = event,
|
||||||
keyPackageBytes = keyPackageBytes,
|
|
||||||
keyPackageEventId = keyPackageEventId,
|
|
||||||
groupRelays = groupRelays,
|
groupRelays = groupRelays,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2086,14 +2070,13 @@ class Account(
|
|||||||
*/
|
*/
|
||||||
suspend fun addMarmotGroupMember(
|
suspend fun addMarmotGroupMember(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
memberPubKey: HexKey,
|
keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
|
||||||
keyPackageBytes: ByteArray,
|
|
||||||
keyPackageEventId: HexKey,
|
|
||||||
groupRelays: List<NormalizedRelayUrl>,
|
groupRelays: List<NormalizedRelayUrl>,
|
||||||
) {
|
) {
|
||||||
|
val memberPubKey = keyPackageEvent.pubKey
|
||||||
Log.d("MarmotDbg") {
|
Log.d("MarmotDbg") {
|
||||||
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " +
|
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " +
|
||||||
"keyPackageBytes=${keyPackageBytes.size}B groupRelays=${groupRelays.size}"
|
"groupRelays=${groupRelays.size}"
|
||||||
}
|
}
|
||||||
val manager = marmotManager ?: return
|
val manager = marmotManager ?: return
|
||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
@@ -2101,9 +2084,7 @@ class Account(
|
|||||||
val (commitEvent, welcomeDelivery) =
|
val (commitEvent, welcomeDelivery) =
|
||||||
manager.addMember(
|
manager.addMember(
|
||||||
nostrGroupId = nostrGroupId,
|
nostrGroupId = nostrGroupId,
|
||||||
memberPubKey = memberPubKey,
|
keyPackageEvent = keyPackageEvent,
|
||||||
keyPackageBytes = keyPackageBytes,
|
|
||||||
keyPackageEventId = keyPackageEventId,
|
|
||||||
relays = groupRelays,
|
relays = groupRelays,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2167,17 +2148,11 @@ class Account(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Relays where this account publishes kind:30443 KeyPackage events.
|
* Relays where this account publishes kind:30443 KeyPackage events.
|
||||||
*
|
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
|
||||||
* Per MIP-00, these should match the relays advertised in the user's
|
|
||||||
* kind:10051 KeyPackage Relay List so that other clients can discover
|
|
||||||
* and fetch them. Falls back to the standard outbox set when no list
|
|
||||||
* has been configured yet, since that's also where the user's other
|
|
||||||
* write-oriented events land.
|
|
||||||
*/
|
*/
|
||||||
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> {
|
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
|
||||||
val list = keyPackageRelayList.flow.value
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||||
return if (list.isNotEmpty()) list else outboxRelays.flow.value
|
.publishRelaysFor(keyPackageRelayList.flow.value, outboxRelays.flow.value)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish or rotate KeyPackage events.
|
* Publish or rotate KeyPackage events.
|
||||||
|
|||||||
+9
-11
@@ -44,7 +44,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
|||||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
|
|
||||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
|
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
|
||||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||||
@@ -223,21 +222,20 @@ class AccountSessionManager(
|
|||||||
loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError)
|
loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||||
}
|
}
|
||||||
} else if (EMAIL_PATTERN.matcher(key).matches()) {
|
} else if (EMAIL_PATTERN.matcher(key).matches()) {
|
||||||
val nip05 = Nip05Id.parse(key)
|
// Delegate to the shared quartz resolver so NIP-05 handling stays in
|
||||||
if (nip05 == null) {
|
// lockstep with the CLI and anywhere else we accept user identifiers.
|
||||||
onError("Could not parse nip05 address: $nip05")
|
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
val pubkeyInfo = nip05ClientBuilder().get(nip05)
|
val hex =
|
||||||
if (pubkeyInfo == null) {
|
com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||||
onError("User not found in the nip05 server: $nip05")
|
.resolveUserHexOrNull(key, nip05ClientBuilder())
|
||||||
|
if (hex == null) {
|
||||||
|
onError("User not found in the nip05 server: $key")
|
||||||
} else {
|
} else {
|
||||||
loginSync(Hex.decode(pubkeyInfo.pubkey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
|
loginSync(Hex.decode(hex).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
onError("Could not load nip05 address from the server: $nip05. ${e.message}")
|
onError("Could not load nip05 address from the server: $key. ${e.message}")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
|
loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||||
|
|||||||
+13
-26
@@ -1453,14 +1453,12 @@ class AccountViewModel(
|
|||||||
nostrGroupId: String,
|
nostrGroupId: String,
|
||||||
text: String,
|
text: String,
|
||||||
) {
|
) {
|
||||||
val template =
|
// Inner event construction lives on MarmotManager so CLI and UI don't drift.
|
||||||
com.vitorpamplona.quartz.nip01Core.signers.eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>(
|
// persistOwn=false because Account.sendMarmotGroupMessage routes the outer
|
||||||
kind = 9,
|
// event through LocalCache which already handles own-message display.
|
||||||
description = text,
|
val bundle = account.marmotManager?.buildTextMessage(nostrGroupId, text, persistOwn = false) ?: return
|
||||||
)
|
|
||||||
val innerEvent = account.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
|
|
||||||
val relays = marmotGroupRelays(nostrGroupId)
|
val relays = marmotGroupRelays(nostrGroupId)
|
||||||
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
|
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendMarmotGroupMediaMessage(
|
suspend fun sendMarmotGroupMediaMessage(
|
||||||
@@ -1558,7 +1556,6 @@ class AccountViewModel(
|
|||||||
name: String,
|
name: String,
|
||||||
description: String,
|
description: String,
|
||||||
) {
|
) {
|
||||||
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
|
|
||||||
// Stamp the inviter's outbox relays into the group metadata so that
|
// Stamp the inviter's outbox relays into the group metadata so that
|
||||||
// every member ends up with a single canonical relay set for kind:445
|
// every member ends up with a single canonical relay set for kind:445
|
||||||
// GroupEvents. Without this, both the inviter and the invitee fall
|
// GroupEvents. Without this, both the inviter and the invitee fall
|
||||||
@@ -1569,29 +1566,19 @@ class AccountViewModel(
|
|||||||
val outboxRelayStrings =
|
val outboxRelayStrings =
|
||||||
account.outboxRelays.flow.value
|
account.outboxRelays.flow.value
|
||||||
.map { it.url }
|
.map { it.url }
|
||||||
val mergedRelays =
|
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
|
||||||
(currentMetadata?.relays.orEmpty() + outboxRelayStrings)
|
|
||||||
.distinct()
|
|
||||||
val updatedMetadata =
|
val updatedMetadata =
|
||||||
if (currentMetadata != null) {
|
currentMetadata
|
||||||
currentMetadata.copy(
|
?.copy(name = name, description = description)
|
||||||
name = name,
|
?.withMergedRelays(outboxRelayStrings)
|
||||||
description = description,
|
?: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||||
relays = mergedRelays,
|
.bootstrap(
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// No MarmotGroupData extension exists yet — this happens for groups
|
|
||||||
// created before initial metadata was persisted, or right after a
|
|
||||||
// fresh `createMarmotGroup`. Build a brand-new extension with the
|
|
||||||
// creator as the sole admin so the GCE proposal carries valid data.
|
|
||||||
com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData(
|
|
||||||
nostrGroupId = nostrGroupId,
|
nostrGroupId = nostrGroupId,
|
||||||
|
creatorPubKey = account.signer.pubKey,
|
||||||
|
outboxRelays = outboxRelayStrings,
|
||||||
name = name,
|
name = name,
|
||||||
description = description,
|
description = description,
|
||||||
adminPubkeys = listOf(account.signer.pubKey),
|
|
||||||
relays = mergedRelays,
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
val relays = marmotGroupRelays(nostrGroupId)
|
val relays = marmotGroupRelays(nostrGroupId)
|
||||||
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore
|
|||||||
import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore
|
import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore
|
||||||
import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore
|
import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore
|
||||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||||
|
import com.vitorpamplona.amethyst.commons.marmot.ingest
|
||||||
import com.vitorpamplona.quartz.marmot.MarmotFilters
|
import com.vitorpamplona.quartz.marmot.MarmotFilters
|
||||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
@@ -72,6 +72,18 @@ class Context(
|
|||||||
websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp },
|
websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-05 resolver for turning `alice@damus.io`-style identifiers into pubkeys.
|
||||||
|
* Uses the same OkHttp instance as the WebSocket client so we share connection
|
||||||
|
* pools and TLS sessions.
|
||||||
|
*/
|
||||||
|
val nip05Client: com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client =
|
||||||
|
com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client(
|
||||||
|
fetcher =
|
||||||
|
com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||||
|
.OkHttpNip05Fetcher { _ -> okhttp },
|
||||||
|
)
|
||||||
|
|
||||||
private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir)
|
private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir)
|
||||||
private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile)
|
private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile)
|
||||||
private val messageStore = FileMarmotMessageStore(dataDir.groupsDir)
|
private val messageStore = FileMarmotMessageStore(dataDir.groupsDir)
|
||||||
@@ -93,6 +105,18 @@ class Context(
|
|||||||
prepared = true
|
prepared = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve `npub…` / `nprofile…` / 64-hex / `name@domain.tld` to a pubkey hex.
|
||||||
|
* Delegates to the shared [resolveUserHexOrNull] in quartz so the UI and CLI
|
||||||
|
* accept the exact same identifier formats. Throws on unrecognised input —
|
||||||
|
* command handlers catch [IllegalArgumentException] at the top level and
|
||||||
|
* translate to `{"error": "bad_args"}`.
|
||||||
|
*/
|
||||||
|
suspend fun requireUserHex(input: String): com.vitorpamplona.quartz.nip01Core.core.HexKey =
|
||||||
|
com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||||
|
.resolveUserHexOrNull(input, nip05Client)
|
||||||
|
?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)")
|
||||||
|
|
||||||
fun outboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("nip65")
|
fun outboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("nip65")
|
||||||
|
|
||||||
fun inboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("inbox")
|
fun inboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("inbox")
|
||||||
@@ -237,39 +261,18 @@ class Context(
|
|||||||
val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap()
|
val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap()
|
||||||
|
|
||||||
for ((relay, event) in events) {
|
for ((relay, event) in events) {
|
||||||
|
// All the MLS/NIP-59 decryption + persistence lives in MarmotIngest —
|
||||||
|
// we only care about bookkeeping (since-cursors, logging) here.
|
||||||
|
val result = marmot.ingest(event)
|
||||||
|
System.err.println("[cli] ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}")
|
||||||
|
|
||||||
when (event.kind) {
|
when (event.kind) {
|
||||||
GiftWrapEvent.KIND -> {
|
GiftWrapEvent.KIND -> {
|
||||||
// kind:1059 — try to unwrap. If it's a Welcome, let the
|
|
||||||
// inbound processor apply it; anything else is ignored.
|
|
||||||
val gw = event as? GiftWrapEvent ?: continue
|
|
||||||
try {
|
|
||||||
val inner = gw.unwrapOrNull(signer) ?: continue
|
|
||||||
if (inner.kind == WelcomeEvent.KIND && inner is WelcomeEvent) {
|
|
||||||
val hint = inner.nostrGroupId()
|
|
||||||
val res = marmot.processWelcome(inner, hint)
|
|
||||||
System.err.println("[cli] Welcome via $relay → $res")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
System.err.println("[cli] failed to unwrap giftwrap ${event.id.take(8)}: ${e.message}")
|
|
||||||
}
|
|
||||||
if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt
|
if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
GroupEvent.KIND -> {
|
GroupEvent.KIND -> {
|
||||||
val ge = event as? GroupEvent ?: continue
|
val gid = (event as? GroupEvent)?.groupId() ?: continue
|
||||||
val gid = ge.groupId() ?: continue
|
|
||||||
try {
|
|
||||||
val res = marmot.processGroupEvent(ge)
|
|
||||||
// Mirror DecryptAndIndexProcessor on Amethyst: application
|
|
||||||
// messages get persisted at decrypt-time, because MLS ratchet
|
|
||||||
// advancement makes the ciphertext un-re-decryptable later.
|
|
||||||
if (res is com.vitorpamplona.quartz.marmot.GroupEventResult.ApplicationMessage) {
|
|
||||||
marmot.persistDecryptedMessage(res.groupId, res.innerEventJson)
|
|
||||||
}
|
|
||||||
System.err.println("[cli] GroupEvent ${event.id.take(8)} → ${res::class.simpleName}")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
System.err.println("[cli] processGroupEvent failed: ${e.message}")
|
|
||||||
}
|
|
||||||
val prev = maxGroupSeen[gid] ?: 0L
|
val prev = maxGroupSeen[gid] ?: 0L
|
||||||
if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt
|
if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.cli.AwaitTimeout
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -59,12 +58,12 @@ object AwaitCommands {
|
|||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
): Int {
|
): Int {
|
||||||
if (rest.isEmpty()) return Json.error("bad_args", "await key-package <npub>")
|
if (rest.isEmpty()) return Json.error("bad_args", "await key-package <npub>")
|
||||||
val target = Npubs.resolveToHex(rest[0])
|
|
||||||
val args = Args(rest.drop(1).toTypedArray())
|
val args = Args(rest.drop(1).toTypedArray())
|
||||||
val timeoutSecs = args.longFlag("timeout", 30)
|
val timeoutSecs = args.longFlag("timeout", 30)
|
||||||
val ctx = Context.open(dataDir)
|
val ctx = Context.open(dataDir)
|
||||||
try {
|
try {
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
|
val target = ctx.requireUserHex(rest[0])
|
||||||
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target)
|
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target)
|
||||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
@@ -129,7 +128,7 @@ object AwaitCommands {
|
|||||||
): Int =
|
): Int =
|
||||||
pollCondition(dataDir, rest, "await member <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
pollCondition(dataDir, rest, "await member <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
||||||
val gid = rawArgs[0]
|
val gid = rawArgs[0]
|
||||||
val target = Npubs.resolveToHex(rawArgs[1])
|
val target = ctx.requireUserHex(rawArgs[1])
|
||||||
if (!ctx.marmot.isMember(gid)) {
|
if (!ctx.marmot.isMember(gid)) {
|
||||||
null
|
null
|
||||||
} else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) {
|
} else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) {
|
||||||
@@ -145,7 +144,7 @@ object AwaitCommands {
|
|||||||
): Int =
|
): Int =
|
||||||
pollCondition(dataDir, rest, "await admin <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
pollCondition(dataDir, rest, "await admin <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
||||||
val gid = rawArgs[0]
|
val gid = rawArgs[0]
|
||||||
val target = Npubs.resolveToHex(rawArgs[1])
|
val target = ctx.requireUserHex(rawArgs[1])
|
||||||
if (!ctx.marmot.isMember(gid)) {
|
if (!ctx.marmot.isMember(gid)) {
|
||||||
null
|
null
|
||||||
} else if (ctx.marmot
|
} else if (ctx.marmot
|
||||||
|
|||||||
+13
-16
@@ -23,11 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `group add <group_id> <npub> [<npub> ...]` — fetch each invitee's
|
* `group add <group_id> <npub> [<npub> ...]` — fetch each invitee's
|
||||||
@@ -37,40 +32,42 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
* gift wrap.
|
* gift wrap.
|
||||||
*/
|
*/
|
||||||
object GroupAddMemberCommand {
|
object GroupAddMemberCommand {
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
|
||||||
suspend fun run(
|
suspend fun run(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
): Int {
|
): Int {
|
||||||
if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
|
if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
|
||||||
val gid = rest[0]
|
val gid = rest[0]
|
||||||
val invitees = rest.drop(1).map { Npubs.resolveToHex(it) }
|
|
||||||
val ctx = Context.open(dataDir)
|
val ctx = Context.open(dataDir)
|
||||||
try {
|
try {
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
ctx.syncIncoming()
|
ctx.syncIncoming()
|
||||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||||
|
|
||||||
|
// Accept any identifier the UI would: npub1…, nprofile1…, 64-hex,
|
||||||
|
// NIP-05 (name@domain). Resolution fires NIP-05 HTTP fetches in parallel
|
||||||
|
// where applicable; bech32/hex stays fully offline.
|
||||||
|
val invitees = rest.drop(1).map { ctx.requireUserHex(it) }
|
||||||
|
|
||||||
val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||||
val report = mutableListOf<Map<String, Any?>>()
|
val report = mutableListOf<Map<String, Any?>>()
|
||||||
|
|
||||||
for (pub in invitees) {
|
for (pub in invitees) {
|
||||||
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(pub)
|
val relays =
|
||||||
val relays = ctx.anyRelays()
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||||
val filters = relays.associateWith { listOf(filter) }
|
.fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays())
|
||||||
val kpEvent = ctx.client.fetchFirst(filters = filters, timeoutMs = 10_000)
|
val kpEvent =
|
||||||
if (kpEvent == null || kpEvent !is KeyPackageEvent) {
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||||
|
.fetchKeyPackage(ctx.client, pub, relays, timeoutMs = 10_000)
|
||||||
|
if (kpEvent == null) {
|
||||||
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
|
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val kpBytes = Base64.decode(kpEvent.keyPackageBase64())
|
|
||||||
|
|
||||||
val (commitEvent, welcomeDelivery) =
|
val (commitEvent, welcomeDelivery) =
|
||||||
ctx.marmot.addMember(
|
ctx.marmot.addMember(
|
||||||
nostrGroupId = gid,
|
nostrGroupId = gid,
|
||||||
memberPubKey = pub,
|
keyPackageEvent = kpEvent,
|
||||||
keyPackageBytes = kpBytes,
|
|
||||||
keyPackageEventId = kpEvent.id,
|
|
||||||
relays = groupRelays.toList(),
|
relays = groupRelays.toList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -42,16 +42,14 @@ object GroupCreateCommand {
|
|||||||
|
|
||||||
ctx.marmot.createGroup(gid)
|
ctx.marmot.createGroup(gid)
|
||||||
|
|
||||||
// Stamp initial metadata: name + our outbox relays + self as admin.
|
// Stamp initial metadata via the shared factory so UI + CLI stay byte-identical.
|
||||||
// Mirrors CreateGroupScreen.proceedWithCreate() on the Amethyst side.
|
|
||||||
val outboxUrls = ctx.outboxRelays().map { it.url }
|
val outboxUrls = ctx.outboxRelays().map { it.url }
|
||||||
val metadata =
|
val metadata =
|
||||||
MarmotGroupData(
|
MarmotGroupData.bootstrap(
|
||||||
nostrGroupId = gid,
|
nostrGroupId = gid,
|
||||||
|
creatorPubKey = ctx.identity.pubKeyHex,
|
||||||
|
outboxRelays = outboxUrls,
|
||||||
name = name,
|
name = name,
|
||||||
description = "",
|
|
||||||
adminPubkeys = listOf(ctx.identity.pubKeyHex),
|
|
||||||
relays = outboxUrls,
|
|
||||||
)
|
)
|
||||||
val commit = ctx.marmot.updateGroupMetadata(gid, metadata)
|
val commit = ctx.marmot.updateGroupMetadata(gid, metadata)
|
||||||
|
|
||||||
|
|||||||
+5
-7
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
|
||||||
|
|
||||||
object GroupMembershipCommands {
|
object GroupMembershipCommands {
|
||||||
suspend fun remove(
|
suspend fun remove(
|
||||||
@@ -32,26 +31,25 @@ object GroupMembershipCommands {
|
|||||||
): Int {
|
): Int {
|
||||||
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
|
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
|
||||||
val gid = rest[0]
|
val gid = rest[0]
|
||||||
val target = Npubs.resolveToHex(rest[1])
|
|
||||||
val ctx = Context.open(dataDir)
|
val ctx = Context.open(dataDir)
|
||||||
try {
|
try {
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
|
val target = ctx.requireUserHex(rest[1])
|
||||||
ctx.syncIncoming()
|
ctx.syncIncoming()
|
||||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||||
|
|
||||||
val member =
|
val leafIndex =
|
||||||
ctx.marmot.memberPubkeys(gid).firstOrNull { it.pubkey == target }
|
ctx.marmot.leafIndexOf(gid, target)
|
||||||
?: return Json.error("not_in_group", target)
|
?: return Json.error("not_in_group", target)
|
||||||
|
|
||||||
val outbound =
|
val outbound = ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = leafIndex)
|
||||||
ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = member.leafIndex)
|
|
||||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||||
val ack = ctx.publish(outbound.signedEvent, targets)
|
val ack = ctx.publish(outbound.signedEvent, targets)
|
||||||
Json.writeLine(
|
Json.writeLine(
|
||||||
mapOf(
|
mapOf(
|
||||||
"group_id" to gid,
|
"group_id" to gid,
|
||||||
"removed" to target,
|
"removed" to target,
|
||||||
"leaf_index" to member.leafIndex,
|
"leaf_index" to leafIndex,
|
||||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||||
"commit_event_id" to outbound.signedEvent.id,
|
"commit_event_id" to outbound.signedEvent.id,
|
||||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||||
|
|||||||
+11
-13
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
|
||||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ object GroupMetadataCommands {
|
|||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
): Int {
|
): Int {
|
||||||
if (rest.size < 2) return Json.error("bad_args", "group rename <gid> <name>")
|
if (rest.size < 2) return Json.error("bad_args", "group rename <gid> <name>")
|
||||||
return edit(dataDir, rest[0]) { it.copy(name = rest[1]) }
|
return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun promote(
|
suspend fun promote(
|
||||||
@@ -45,8 +44,8 @@ object GroupMetadataCommands {
|
|||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
): Int {
|
): Int {
|
||||||
if (rest.size < 2) return Json.error("bad_args", "group promote <gid> <npub>")
|
if (rest.size < 2) return Json.error("bad_args", "group promote <gid> <npub>")
|
||||||
val newAdmin = Npubs.resolveToHex(rest[1])
|
return edit(dataDir, rest[0]) { ctx, cur ->
|
||||||
return edit(dataDir, rest[0]) { cur ->
|
val newAdmin = ctx.requireUserHex(rest[1])
|
||||||
val admins = cur.adminPubkeys.toMutableList()
|
val admins = cur.adminPubkeys.toMutableList()
|
||||||
if (newAdmin !in admins) admins.add(newAdmin)
|
if (newAdmin !in admins) admins.add(newAdmin)
|
||||||
cur.copy(adminPubkeys = admins)
|
cur.copy(adminPubkeys = admins)
|
||||||
@@ -58,8 +57,8 @@ object GroupMetadataCommands {
|
|||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
): Int {
|
): Int {
|
||||||
if (rest.size < 2) return Json.error("bad_args", "group demote <gid> <npub>")
|
if (rest.size < 2) return Json.error("bad_args", "group demote <gid> <npub>")
|
||||||
val target = Npubs.resolveToHex(rest[1])
|
return edit(dataDir, rest[0]) { ctx, cur ->
|
||||||
return edit(dataDir, rest[0]) { cur ->
|
val target = ctx.requireUserHex(rest[1])
|
||||||
val admins = cur.adminPubkeys.filter { it != target }
|
val admins = cur.adminPubkeys.filter { it != target }
|
||||||
cur.copy(adminPubkeys = admins)
|
cur.copy(adminPubkeys = admins)
|
||||||
}
|
}
|
||||||
@@ -68,23 +67,22 @@ object GroupMetadataCommands {
|
|||||||
private suspend fun edit(
|
private suspend fun edit(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
gid: HexKey,
|
gid: HexKey,
|
||||||
mutate: (MarmotGroupData) -> MarmotGroupData,
|
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
|
||||||
): Int {
|
): Int {
|
||||||
val ctx = Context.open(dataDir)
|
val ctx = Context.open(dataDir)
|
||||||
try {
|
try {
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
ctx.syncIncoming()
|
ctx.syncIncoming()
|
||||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||||
|
val outboxUrls = ctx.outboxRelays().map { it.url }
|
||||||
val cur =
|
val cur =
|
||||||
ctx.marmot.groupMetadata(gid)
|
ctx.marmot.groupMetadata(gid)
|
||||||
?: MarmotGroupData(
|
?: MarmotGroupData.bootstrap(
|
||||||
nostrGroupId = gid,
|
nostrGroupId = gid,
|
||||||
name = "",
|
creatorPubKey = ctx.identity.pubKeyHex,
|
||||||
description = "",
|
outboxRelays = outboxUrls,
|
||||||
adminPubkeys = listOf(ctx.identity.pubKeyHex),
|
|
||||||
relays = ctx.outboxRelays().map { it.url },
|
|
||||||
)
|
)
|
||||||
val updated = mutate(cur)
|
val updated = mutate(ctx, cur).withMergedRelays(outboxUrls)
|
||||||
|
|
||||||
val commit = ctx.marmot.updateGroupMetadata(gid, updated)
|
val commit = ctx.marmot.updateGroupMetadata(gid, updated)
|
||||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
|
||||||
|
|
||||||
object KeyPackageCommands {
|
object KeyPackageCommands {
|
||||||
suspend fun dispatch(
|
suspend fun dispatch(
|
||||||
@@ -68,16 +65,20 @@ object KeyPackageCommands {
|
|||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
): Int {
|
): Int {
|
||||||
if (rest.isEmpty()) return Json.error("bad_args", "key-package check <npub>")
|
if (rest.isEmpty()) return Json.error("bad_args", "key-package check <npub>")
|
||||||
val targetHex = Npubs.resolveToHex(rest[0])
|
|
||||||
val ctx = Context.open(dataDir)
|
val ctx = Context.open(dataDir)
|
||||||
try {
|
try {
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(targetHex)
|
val targetHex = ctx.requireUserHex(rest[0])
|
||||||
val relays = ctx.anyRelays()
|
// CLI doesn't (yet) cache target's kind:10051/10002 — just ask every
|
||||||
|
// configured relay. Amethyst, which does cache those, passes them in.
|
||||||
|
val relays =
|
||||||
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||||
|
.fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays())
|
||||||
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
|
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
|
||||||
val filtersByRelay = relays.associateWith { listOf(filter) }
|
val event =
|
||||||
val event = ctx.client.fetchFirst(filters = filtersByRelay, timeoutMs = 10_000)
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||||
if (event == null || event !is KeyPackageEvent) {
|
.fetchKeyPackage(ctx.client, targetHex, relays, timeoutMs = 10_000)
|
||||||
|
if (event == null) {
|
||||||
return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
|
return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
|
||||||
}
|
}
|
||||||
Json.writeLine(
|
Json.writeLine(
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.cli.Args
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
|
||||||
|
|
||||||
object MessageCommands {
|
object MessageCommands {
|
||||||
suspend fun dispatch(
|
suspend fun dispatch(
|
||||||
@@ -54,24 +53,16 @@ object MessageCommands {
|
|||||||
ctx.syncIncoming()
|
ctx.syncIncoming()
|
||||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||||
|
|
||||||
val template =
|
val bundle = ctx.marmot.buildTextMessage(gid, text)
|
||||||
eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>(kind = 9, description = text)
|
|
||||||
val innerEvent = ctx.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
|
|
||||||
val outbound = ctx.marmot.buildGroupMessage(gid, innerEvent)
|
|
||||||
|
|
||||||
// Persist our own outbound inner event so `message list` includes it
|
|
||||||
// alongside remote messages.
|
|
||||||
ctx.marmot.persistDecryptedMessage(gid, innerEvent.toJson())
|
|
||||||
|
|
||||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||||
val ack = ctx.publish(outbound.signedEvent, targets)
|
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
|
||||||
|
|
||||||
Json.writeLine(
|
Json.writeLine(
|
||||||
mapOf(
|
mapOf(
|
||||||
"group_id" to gid,
|
"group_id" to gid,
|
||||||
"inner_event_id" to innerEvent.id,
|
"inner_event_id" to bundle.innerEvent.id,
|
||||||
"outer_event_id" to outbound.signedEvent.id,
|
"outer_event_id" to bundle.outbound.signedEvent.id,
|
||||||
"kind" to innerEvent.kind,
|
"kind" to bundle.innerEvent.kind,
|
||||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Vitor Pamplona
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
* this software and associated documentation files (the "Software"), to deal in
|
|
||||||
* the Software without restriction, including without limitation the rights to use,
|
|
||||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
|
||||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
* subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in all
|
|
||||||
* copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
|
||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
||||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
*/
|
|
||||||
package com.vitorpamplona.amethyst.cli.util
|
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
|
||||||
|
|
||||||
/** Accept either an `npub1…` or a raw 64-hex pubkey; always return hex. */
|
|
||||||
object Npubs {
|
|
||||||
fun resolveToHex(input: String): String {
|
|
||||||
val trimmed = input.trim()
|
|
||||||
// Already hex?
|
|
||||||
if (trimmed.length == 64 && trimmed.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) {
|
|
||||||
return trimmed.lowercase()
|
|
||||||
}
|
|
||||||
if (trimmed.startsWith("npub1") || trimmed.startsWith("npub")) {
|
|
||||||
return trimmed.bechToBytes().toHexKey()
|
|
||||||
}
|
|
||||||
throw IllegalArgumentException("expected npub or 64-hex pubkey, got: $input")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.marmot
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.marmot.GroupEventResult
|
||||||
|
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
|
||||||
|
import com.vitorpamplona.quartz.marmot.WelcomeResult
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of routing a single inbound Nostr event through the Marmot pipeline.
|
||||||
|
*
|
||||||
|
* Drives both Amethyst's `DecryptAndIndexProcessor.GroupEventHandler` and the
|
||||||
|
* CLI's `Context.syncIncoming`. Platform-specific side effects (UI notifications,
|
||||||
|
* "mark as known", cache hints) happen at the call site based on which variant
|
||||||
|
* came back; the ingest method handles only the MLS/crypto parts that are the
|
||||||
|
* same everywhere.
|
||||||
|
*/
|
||||||
|
sealed class MarmotIngestResult {
|
||||||
|
/** A Welcome arrived and we successfully joined a new group. */
|
||||||
|
data class JoinedGroup(
|
||||||
|
val nostrGroupId: HexKey,
|
||||||
|
val needsKeyPackageRotation: Boolean,
|
||||||
|
) : MarmotIngestResult()
|
||||||
|
|
||||||
|
/** A Welcome for a group we're already in — benign replay. */
|
||||||
|
data class AlreadyInGroup(
|
||||||
|
val nostrGroupId: HexKey,
|
||||||
|
) : MarmotIngestResult()
|
||||||
|
|
||||||
|
/** A kind:445 carried an application message we decrypted. Already persisted. */
|
||||||
|
data class Message(
|
||||||
|
val inner: GroupEventResult.ApplicationMessage,
|
||||||
|
) : MarmotIngestResult()
|
||||||
|
|
||||||
|
/** A kind:445 carried a commit that advanced the group epoch. */
|
||||||
|
data class Commit(
|
||||||
|
val inner: GroupEventResult.CommitProcessed,
|
||||||
|
) : MarmotIngestResult()
|
||||||
|
|
||||||
|
/** A kind:445 whose outer layer we couldn't decrypt (pre-join epoch, etc). Debug-only. */
|
||||||
|
data class UndecryptableOuter(
|
||||||
|
val groupId: HexKey,
|
||||||
|
val retainedEpochCount: Int,
|
||||||
|
) : MarmotIngestResult()
|
||||||
|
|
||||||
|
/** Deduplicate / out-of-order commits / unsupported content. Not an error. */
|
||||||
|
data object Ignored : MarmotIngestResult()
|
||||||
|
|
||||||
|
/** Something blew up. Callers log. */
|
||||||
|
data class Failure(
|
||||||
|
val message: String,
|
||||||
|
val cause: Throwable? = null,
|
||||||
|
) : MarmotIngestResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route a single event through the Marmot inbound pipeline.
|
||||||
|
*
|
||||||
|
* Kind 1059 (gift wraps): unwrap → if inner kind:444, process Welcome.
|
||||||
|
* Kind 445 (group events): decrypt + process; on [GroupEventResult.ApplicationMessage],
|
||||||
|
* persist the decrypted inner JSON so [MarmotManager.loadStoredMessages] sees it.
|
||||||
|
*
|
||||||
|
* Other kinds are returned as [MarmotIngestResult.Ignored] — callers decide
|
||||||
|
* what to do with them (most route through their own feed ingestion).
|
||||||
|
*/
|
||||||
|
suspend fun MarmotManager.ingest(event: Event): MarmotIngestResult =
|
||||||
|
when (event) {
|
||||||
|
is GiftWrapEvent -> ingestGiftWrap(event)
|
||||||
|
is GroupEvent -> ingestGroupEvent(event)
|
||||||
|
else -> MarmotIngestResult.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult =
|
||||||
|
try {
|
||||||
|
val inner = wrap.unwrapOrNull(signer) ?: return MarmotIngestResult.Ignored
|
||||||
|
if (!MarmotInboundProcessor.isWelcomeEvent(inner) || inner !is WelcomeEvent) {
|
||||||
|
return MarmotIngestResult.Ignored
|
||||||
|
}
|
||||||
|
when (val result = processWelcome(inner, inner.nostrGroupId())) {
|
||||||
|
is WelcomeResult.Joined -> {
|
||||||
|
MarmotIngestResult.JoinedGroup(
|
||||||
|
nostrGroupId = result.nostrGroupId,
|
||||||
|
needsKeyPackageRotation = result.needsKeyPackageRotation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is WelcomeResult.AlreadyJoined -> {
|
||||||
|
MarmotIngestResult.AlreadyInGroup(result.nostrGroupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is WelcomeResult.Error -> {
|
||||||
|
MarmotIngestResult.Failure(result.message, result.cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MarmotIngestResult.Failure("giftwrap unwrap failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun MarmotManager.ingestGroupEvent(ge: GroupEvent): MarmotIngestResult =
|
||||||
|
when (val result = processGroupEvent(ge)) {
|
||||||
|
is GroupEventResult.ApplicationMessage -> {
|
||||||
|
// MLS ratchets once we decrypt; future reads of the same ciphertext
|
||||||
|
// would fail — persist the plaintext now so restarts/replays see it.
|
||||||
|
persistDecryptedMessage(result.groupId, result.innerEventJson)
|
||||||
|
MarmotIngestResult.Message(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
is GroupEventResult.CommitProcessed -> {
|
||||||
|
MarmotIngestResult.Commit(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
is GroupEventResult.Duplicate,
|
||||||
|
is GroupEventResult.CommitPending,
|
||||||
|
-> {
|
||||||
|
MarmotIngestResult.Ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
is GroupEventResult.UndecryptableOuterLayer -> {
|
||||||
|
MarmotIngestResult.UndecryptableOuter(result.groupId, result.retainedEpochCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
is GroupEventResult.Error -> {
|
||||||
|
MarmotIngestResult.Failure(result.message, result.cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -154,6 +154,56 @@ class MarmotManager(
|
|||||||
innerEvent: Event,
|
innerEvent: Event,
|
||||||
): OutboundGroupEvent = outboundProcessor.buildGroupEvent(nostrGroupId, innerEvent)
|
): OutboundGroupEvent = outboundProcessor.buildGroupEvent(nostrGroupId, innerEvent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a kind:9 chat-message GroupEvent from plain text. The inner event is
|
||||||
|
* signed with this manager's signer and optionally persisted to the local
|
||||||
|
* decrypted-message log so `loadStoredMessages` reflects our own outbound
|
||||||
|
* immediately (without waiting for relay loopback).
|
||||||
|
*
|
||||||
|
* Platform callers that already maintain their own "own event" cache (i.e.
|
||||||
|
* Amethyst's `LocalCache.justConsumeMyOwnEvent`) should pass `persistOwn = false`.
|
||||||
|
* Headless callers (CLI) should leave it at the default.
|
||||||
|
*
|
||||||
|
* @return the signed kind:445 outer event together with the inner kind:9
|
||||||
|
* event id, so the caller can reference it for replies/reactions.
|
||||||
|
*/
|
||||||
|
suspend fun buildTextMessage(
|
||||||
|
nostrGroupId: HexKey,
|
||||||
|
text: String,
|
||||||
|
persistOwn: Boolean = true,
|
||||||
|
): TextMessageBundle {
|
||||||
|
val template =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.signers
|
||||||
|
.eventTemplate<Event>(kind = 9, description = text)
|
||||||
|
val innerEvent = signer.sign<Event>(template)
|
||||||
|
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
|
||||||
|
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
|
||||||
|
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a member to a group by consuming their published [KeyPackageEvent].
|
||||||
|
*
|
||||||
|
* Convenience over [addMember] that handles base64 decoding and lifts the
|
||||||
|
* event id into the WelcomeDelivery. Prefer this overload — both the UI's
|
||||||
|
* `Account.addMarmotGroupMember` and the CLI's `group add` command call it.
|
||||||
|
*/
|
||||||
|
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
|
||||||
|
suspend fun addMember(
|
||||||
|
nostrGroupId: HexKey,
|
||||||
|
keyPackageEvent: KeyPackageEvent,
|
||||||
|
relays: List<NormalizedRelayUrl>,
|
||||||
|
): Pair<OutboundGroupEvent, WelcomeDelivery?> =
|
||||||
|
addMember(
|
||||||
|
nostrGroupId = nostrGroupId,
|
||||||
|
memberPubKey = keyPackageEvent.pubKey,
|
||||||
|
keyPackageBytes =
|
||||||
|
kotlin.io.encoding.Base64
|
||||||
|
.decode(keyPackageEvent.keyPackageBase64()),
|
||||||
|
keyPackageEventId = keyPackageEvent.id,
|
||||||
|
relays = relays,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a member to a group.
|
* Add a member to a group.
|
||||||
* Returns the commit GroupEvent to publish, and the WelcomeDelivery for the new member.
|
* Returns the commit GroupEvent to publish, and the WelcomeDelivery for the new member.
|
||||||
@@ -430,6 +480,19 @@ class MarmotManager(
|
|||||||
*/
|
*/
|
||||||
fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch
|
fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the MLS leaf index for a member by Nostr pubkey, or null if that
|
||||||
|
* pubkey isn't currently in the group.
|
||||||
|
*
|
||||||
|
* `removeMember` needs a leaf index; every caller (UI remove-member dialog
|
||||||
|
* and CLI `group remove`) previously did its own pubkey→leaf lookup through
|
||||||
|
* [memberPubkeys]. Centralised here so the scan lives in one place.
|
||||||
|
*/
|
||||||
|
fun leafIndexOf(
|
||||||
|
nostrGroupId: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
): Int? = memberPubkeys(nostrGroupId).firstOrNull { it.pubkey == pubKey }?.leafIndex
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the MIP-04 media exporter secret for a group.
|
* Get the MIP-04 media exporter secret for a group.
|
||||||
* MLS-Exporter("marmot", "encrypted-media", 32)
|
* MLS-Exporter("marmot", "encrypted-media", 32)
|
||||||
@@ -477,3 +540,13 @@ data class GroupMemberInfo(
|
|||||||
val leafIndex: Int,
|
val leafIndex: Int,
|
||||||
val pubkey: HexKey,
|
val pubkey: HexKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of [MarmotManager.buildTextMessage]: the signed outer kind:445 event
|
||||||
|
* to publish on group relays, plus the inner kind:9 event (for callers that
|
||||||
|
* need the inner id to reference it in replies or reactions).
|
||||||
|
*/
|
||||||
|
data class TextMessageBundle(
|
||||||
|
val outbound: OutboundGroupEvent,
|
||||||
|
val innerEvent: Event,
|
||||||
|
)
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* 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.mip00KeyPackages
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.marmot.MarmotFilters
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovery helpers for MIP-00 KeyPackages.
|
||||||
|
*
|
||||||
|
* Pulled out of Amethyst's `Account.fetchKeyPackageAndAddMember` so the CLI
|
||||||
|
* (and any future non-Android caller) does not re-implement the same union-
|
||||||
|
* of-relays logic when inviting a user to a Marmot group.
|
||||||
|
*
|
||||||
|
* This is the lowest-useful layer — it knows *nothing* about how callers
|
||||||
|
* discover the target user's kind:10051 (KeyPackage Relay List) or kind:10002
|
||||||
|
* (NIP-65 outbox) — those live in platform-specific caches. Callers collect
|
||||||
|
* those sets themselves and pass them in.
|
||||||
|
*/
|
||||||
|
object KeyPackageFetcher {
|
||||||
|
/**
|
||||||
|
* Union of the three relay sets we'd ever want to query for a given user's
|
||||||
|
* KeyPackage, in priority order of specificity:
|
||||||
|
*
|
||||||
|
* 1. target's kind:10051 KeyPackage Relay List (most authoritative)
|
||||||
|
* 2. target's kind:10002 NIP-65 outbox (where the user publishes in general)
|
||||||
|
* 3. our own outbox (shared-relay fallback — someone publishing and us
|
||||||
|
* reading often overlap here)
|
||||||
|
*/
|
||||||
|
fun fetchRelaysFor(
|
||||||
|
targetKeyPackageRelays: Collection<NormalizedRelayUrl>,
|
||||||
|
targetOutbox: Collection<NormalizedRelayUrl>,
|
||||||
|
myOutbox: Collection<NormalizedRelayUrl>,
|
||||||
|
): Set<NormalizedRelayUrl> =
|
||||||
|
buildSet {
|
||||||
|
addAll(targetKeyPackageRelays)
|
||||||
|
addAll(targetOutbox)
|
||||||
|
addAll(myOutbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot fetch of a user's KeyPackage across [relays], returning the first
|
||||||
|
* matching event or `null` if none of the relays yielded one before timeout.
|
||||||
|
*/
|
||||||
|
suspend fun fetchKeyPackage(
|
||||||
|
client: INostrClient,
|
||||||
|
targetPubKey: HexKey,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
timeoutMs: Long = 30_000,
|
||||||
|
): KeyPackageEvent? {
|
||||||
|
if (relays.isEmpty()) return null
|
||||||
|
val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey)
|
||||||
|
val event = client.fetchFirst(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs)
|
||||||
|
return event as? KeyPackageEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which relays this account should publish its OWN KeyPackage to.
|
||||||
|
*
|
||||||
|
* Per MIP-00, a user's KeyPackages SHOULD live on the relays listed in their
|
||||||
|
* kind:10051 KeyPackageRelayListEvent. If they haven't published one yet,
|
||||||
|
* fall back to their NIP-65 outbox — that's where their other write-oriented
|
||||||
|
* events land and it keeps discovery working in the common "just starting out"
|
||||||
|
* case.
|
||||||
|
*/
|
||||||
|
fun publishRelaysFor(
|
||||||
|
keyPackageRelayList: Collection<NormalizedRelayUrl>,
|
||||||
|
myOutbox: Collection<NormalizedRelayUrl>,
|
||||||
|
): Set<NormalizedRelayUrl> = if (keyPackageRelayList.isNotEmpty()) keyPackageRelayList.toSet() else myOutbox.toSet()
|
||||||
|
}
|
||||||
+39
@@ -111,6 +111,21 @@ data class MarmotGroupData(
|
|||||||
/** Whether this group has an encrypted image set */
|
/** Whether this group has an encrypted image set */
|
||||||
fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null
|
fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a copy with [newRelays] unioned into [relays], de-duplicated and order-preserving.
|
||||||
|
*
|
||||||
|
* Every metadata-updating commit produced by the group creator or an admin SHOULD fold
|
||||||
|
* its author's own outbox into [relays] so new invitees learn a single canonical relay
|
||||||
|
* set for kind:445 from the Welcome, even when the inviter's outbox has drifted since
|
||||||
|
* the last commit. Both the UI and the CLI need this same merge rule — hence living
|
||||||
|
* on the data class.
|
||||||
|
*/
|
||||||
|
fun withMergedRelays(newRelays: Collection<String>): MarmotGroupData {
|
||||||
|
if (newRelays.isEmpty()) return this
|
||||||
|
val merged = (relays + newRelays).distinct()
|
||||||
|
return if (merged == relays) this else copy(relays = merged)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode this MarmotGroupData to TLS wire format bytes.
|
* Encode this MarmotGroupData to TLS wire format bytes.
|
||||||
* Mirrors the [decodeTls] format.
|
* Mirrors the [decodeTls] format.
|
||||||
@@ -185,6 +200,30 @@ data class MarmotGroupData(
|
|||||||
const val EXTENSION_ID: UShort = 0xF2EEu
|
const val EXTENSION_ID: UShort = 0xF2EEu
|
||||||
const val EXTENSION_ID_INT: Int = 0xF2EE
|
const val EXTENSION_ID_INT: Int = 0xF2EE
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a freshly-minted [MarmotGroupData] for a group with no prior metadata —
|
||||||
|
* i.e. right after `MlsGroupManager.createGroup`. Creator becomes the sole admin
|
||||||
|
* and their outbox relays are stamped as the group's relay set.
|
||||||
|
*
|
||||||
|
* Both the Android UI (`AccountViewModel.updateMarmotGroupMetadata`) and the
|
||||||
|
* headless CLI need this same initial shape; keeping the factory here avoids
|
||||||
|
* subtle drift between them.
|
||||||
|
*/
|
||||||
|
fun bootstrap(
|
||||||
|
nostrGroupId: HexKey,
|
||||||
|
creatorPubKey: HexKey,
|
||||||
|
outboxRelays: Collection<String>,
|
||||||
|
name: String = "",
|
||||||
|
description: String = "",
|
||||||
|
): MarmotGroupData =
|
||||||
|
MarmotGroupData(
|
||||||
|
nostrGroupId = nostrGroupId,
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
adminPubkeys = listOf(creatorPubKey),
|
||||||
|
relays = outboxRelays.distinct(),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find and decode the MarmotGroupData extension from a list of MLS extensions.
|
* Find and decode the MarmotGroupData extension from a list of MLS extensions.
|
||||||
* Returns null if no extension with type 0xF2EE is present or if decoding fails.
|
* Returns null if no extension with type 0xF2EE is present or if decoding fails.
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip05DnsIdentifiers
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve any user identifier the UI or a CLI might throw at us to a 64-hex
|
||||||
|
* Nostr pubkey. Accepts:
|
||||||
|
*
|
||||||
|
* - raw 64-hex pubkey
|
||||||
|
* - bech32 `npub1…`
|
||||||
|
* - bech32 `nprofile1…` (extracts the pubkey component)
|
||||||
|
* - bech32 `nsec1…` (derives the public key from the secret)
|
||||||
|
* - NIP-05 internet identifier (`name@domain.tld` or bare domain)
|
||||||
|
*
|
||||||
|
* Returns null when the input doesn't match any of the above or when a NIP-05
|
||||||
|
* lookup fails (network error, no match on server). Throws only on
|
||||||
|
* [CancellationException] so callers can still use structured concurrency.
|
||||||
|
*
|
||||||
|
* Pass a non-null [nip05Client] to enable NIP-05 resolution. When it's null,
|
||||||
|
* NIP-05-shaped inputs fall through and this returns null — that's the right
|
||||||
|
* behaviour for call sites that don't have HTTP access (e.g. a pure-offline
|
||||||
|
* context).
|
||||||
|
*/
|
||||||
|
suspend fun resolveUserHexOrNull(
|
||||||
|
input: String,
|
||||||
|
nip05Client: INip05Client? = null,
|
||||||
|
): HexKey? {
|
||||||
|
val trimmed = input.trim()
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
|
||||||
|
// First try the bech32/hex path — it's pure and synchronous.
|
||||||
|
decodePublicKeyAsHexOrNull(trimmed)?.let { return it }
|
||||||
|
|
||||||
|
// NIP-05: name@domain.tld (bare `@domain` = root "_" account).
|
||||||
|
if (looksLikeNip05(trimmed) && nip05Client != null) {
|
||||||
|
try {
|
||||||
|
val id = Nip05Id.parse(trimmed) ?: return null
|
||||||
|
return nip05Client.get(id)?.pubkey
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap precheck — a plausible NIP-05 identifier has an `@` followed by at
|
||||||
|
* least one dot. Keeps us from issuing HTTP fetches on obvious non-matches
|
||||||
|
* like hex strings or single bech32 tokens.
|
||||||
|
*/
|
||||||
|
internal fun looksLikeNip05(value: String): Boolean {
|
||||||
|
val at = value.indexOf('@')
|
||||||
|
if (at <= 0 || at == value.length - 1) return false
|
||||||
|
val afterAt = value.substring(at + 1)
|
||||||
|
return afterAt.contains('.') && !afterAt.contains('@')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user