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"
|
||||
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
|
||||
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
|
||||
// there first, then fall back to the invitee's NIP-65 outbox
|
||||
@@ -2019,20 +2016,18 @@ class Account(
|
||||
.outboxRelays()
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
val fetchRelays = memberKeyPackageRelays + memberOutbox + myOutbox
|
||||
val fetchRelays =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
|
||||
"(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 =
|
||||
client.fetchFirst(
|
||||
filters = filterMap,
|
||||
)
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchKeyPackage(client, memberPubKey, fetchRelays)
|
||||
|
||||
if (event == null) {
|
||||
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)}…"
|
||||
}
|
||||
|
||||
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()
|
||||
if (keyPackageBase64.isBlank()) {
|
||||
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: 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
|
||||
// where to subscribe for subsequent GroupEvents. Use our own
|
||||
// outbox — that's where we will publish them.
|
||||
@@ -2071,9 +2057,7 @@ class Account(
|
||||
|
||||
addMarmotGroupMember(
|
||||
nostrGroupId = nostrGroupId,
|
||||
memberPubKey = memberPubKey,
|
||||
keyPackageBytes = keyPackageBytes,
|
||||
keyPackageEventId = keyPackageEventId,
|
||||
keyPackageEvent = event,
|
||||
groupRelays = groupRelays,
|
||||
)
|
||||
|
||||
@@ -2086,14 +2070,13 @@ class Account(
|
||||
*/
|
||||
suspend fun addMarmotGroupMember(
|
||||
nostrGroupId: HexKey,
|
||||
memberPubKey: HexKey,
|
||||
keyPackageBytes: ByteArray,
|
||||
keyPackageEventId: HexKey,
|
||||
keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
|
||||
groupRelays: List<NormalizedRelayUrl>,
|
||||
) {
|
||||
val memberPubKey = keyPackageEvent.pubKey
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " +
|
||||
"keyPackageBytes=${keyPackageBytes.size}B groupRelays=${groupRelays.size}"
|
||||
"groupRelays=${groupRelays.size}"
|
||||
}
|
||||
val manager = marmotManager ?: return
|
||||
if (!isWriteable()) return
|
||||
@@ -2101,9 +2084,7 @@ class Account(
|
||||
val (commitEvent, welcomeDelivery) =
|
||||
manager.addMember(
|
||||
nostrGroupId = nostrGroupId,
|
||||
memberPubKey = memberPubKey,
|
||||
keyPackageBytes = keyPackageBytes,
|
||||
keyPackageEventId = keyPackageEventId,
|
||||
keyPackageEvent = keyPackageEvent,
|
||||
relays = groupRelays,
|
||||
)
|
||||
|
||||
@@ -2167,17 +2148,11 @@ class Account(
|
||||
|
||||
/**
|
||||
* Relays where this account publishes kind:30443 KeyPackage events.
|
||||
*
|
||||
* 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.
|
||||
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
|
||||
*/
|
||||
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> {
|
||||
val list = keyPackageRelayList.flow.value
|
||||
return if (list.isNotEmpty()) list else outboxRelays.flow.value
|
||||
}
|
||||
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.publishRelaysFor(keyPackageRelayList.flow.value, outboxRelays.flow.value)
|
||||
|
||||
/**
|
||||
* Publish or rotate KeyPackage events.
|
||||
|
||||
+13
-15
@@ -44,7 +44,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
|
||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
@@ -223,21 +222,20 @@ class AccountSessionManager(
|
||||
loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
}
|
||||
} else if (EMAIL_PATTERN.matcher(key).matches()) {
|
||||
val nip05 = Nip05Id.parse(key)
|
||||
if (nip05 == null) {
|
||||
onError("Could not parse nip05 address: $nip05")
|
||||
} else {
|
||||
try {
|
||||
val pubkeyInfo = nip05ClientBuilder().get(nip05)
|
||||
if (pubkeyInfo == null) {
|
||||
onError("User not found in the nip05 server: $nip05")
|
||||
} else {
|
||||
loginSync(Hex.decode(pubkeyInfo.pubkey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Could not load nip05 address from the server: $nip05. ${e.message}")
|
||||
// Delegate to the shared quartz resolver so NIP-05 handling stays in
|
||||
// lockstep with the CLI and anywhere else we accept user identifiers.
|
||||
try {
|
||||
val hex =
|
||||
com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||
.resolveUserHexOrNull(key, nip05ClientBuilder())
|
||||
if (hex == null) {
|
||||
onError("User not found in the nip05 server: $key")
|
||||
} else {
|
||||
loginSync(Hex.decode(hex).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Could not load nip05 address from the server: $key. ${e.message}")
|
||||
}
|
||||
} else {
|
||||
loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
|
||||
+17
-30
@@ -1453,14 +1453,12 @@ class AccountViewModel(
|
||||
nostrGroupId: String,
|
||||
text: String,
|
||||
) {
|
||||
val template =
|
||||
com.vitorpamplona.quartz.nip01Core.signers.eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>(
|
||||
kind = 9,
|
||||
description = text,
|
||||
)
|
||||
val innerEvent = account.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
|
||||
// Inner event construction lives on MarmotManager so CLI and UI don't drift.
|
||||
// persistOwn=false because Account.sendMarmotGroupMessage routes the outer
|
||||
// event through LocalCache which already handles own-message display.
|
||||
val bundle = account.marmotManager?.buildTextMessage(nostrGroupId, text, persistOwn = false) ?: return
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
|
||||
}
|
||||
|
||||
suspend fun sendMarmotGroupMediaMessage(
|
||||
@@ -1558,7 +1556,6 @@ class AccountViewModel(
|
||||
name: String,
|
||||
description: String,
|
||||
) {
|
||||
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
|
||||
// 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
|
||||
// GroupEvents. Without this, both the inviter and the invitee fall
|
||||
@@ -1569,29 +1566,19 @@ class AccountViewModel(
|
||||
val outboxRelayStrings =
|
||||
account.outboxRelays.flow.value
|
||||
.map { it.url }
|
||||
val mergedRelays =
|
||||
(currentMetadata?.relays.orEmpty() + outboxRelayStrings)
|
||||
.distinct()
|
||||
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
|
||||
val updatedMetadata =
|
||||
if (currentMetadata != null) {
|
||||
currentMetadata.copy(
|
||||
name = name,
|
||||
description = description,
|
||||
relays = mergedRelays,
|
||||
)
|
||||
} 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,
|
||||
name = name,
|
||||
description = description,
|
||||
adminPubkeys = listOf(account.signer.pubKey),
|
||||
relays = mergedRelays,
|
||||
)
|
||||
}
|
||||
currentMetadata
|
||||
?.copy(name = name, description = description)
|
||||
?.withMergedRelays(outboxRelayStrings)
|
||||
?: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
.bootstrap(
|
||||
nostrGroupId = nostrGroupId,
|
||||
creatorPubKey = account.signer.pubKey,
|
||||
outboxRelays = outboxRelayStrings,
|
||||
name = name,
|
||||
description = description,
|
||||
)
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
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.FileMlsGroupStateStore
|
||||
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.mip02Welcome.WelcomeEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -72,6 +72,18 @@ class Context(
|
||||
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 keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile)
|
||||
private val messageStore = FileMarmotMessageStore(dataDir.groupsDir)
|
||||
@@ -93,6 +105,18 @@ class Context(
|
||||
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 inboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("inbox")
|
||||
@@ -237,39 +261,18 @@ class Context(
|
||||
val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap()
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
GroupEvent.KIND -> {
|
||||
val ge = event as? GroupEvent ?: 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 gid = (event as? GroupEvent)?.groupId() ?: continue
|
||||
val prev = maxGroupSeen[gid] ?: 0L
|
||||
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.DataDir
|
||||
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 kotlinx.coroutines.delay
|
||||
@@ -59,12 +58,12 @@ object AwaitCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
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 timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val target = ctx.requireUserHex(rest[0])
|
||||
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target)
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
@@ -129,7 +128,7 @@ object AwaitCommands {
|
||||
): Int =
|
||||
pollCondition(dataDir, rest, "await member <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
||||
val gid = rawArgs[0]
|
||||
val target = Npubs.resolveToHex(rawArgs[1])
|
||||
val target = ctx.requireUserHex(rawArgs[1])
|
||||
if (!ctx.marmot.isMember(gid)) {
|
||||
null
|
||||
} else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) {
|
||||
@@ -145,7 +144,7 @@ object AwaitCommands {
|
||||
): Int =
|
||||
pollCondition(dataDir, rest, "await admin <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
||||
val gid = rawArgs[0]
|
||||
val target = Npubs.resolveToHex(rawArgs[1])
|
||||
val target = ctx.requireUserHex(rawArgs[1])
|
||||
if (!ctx.marmot.isMember(gid)) {
|
||||
null
|
||||
} 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.DataDir
|
||||
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
|
||||
@@ -37,40 +32,42 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
* gift wrap.
|
||||
*/
|
||||
object GroupAddMemberCommand {
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
|
||||
val gid = rest[0]
|
||||
val invitees = rest.drop(1).map { Npubs.resolveToHex(it) }
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
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 report = mutableListOf<Map<String, Any?>>()
|
||||
|
||||
for (pub in invitees) {
|
||||
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(pub)
|
||||
val relays = ctx.anyRelays()
|
||||
val filters = relays.associateWith { listOf(filter) }
|
||||
val kpEvent = ctx.client.fetchFirst(filters = filters, timeoutMs = 10_000)
|
||||
if (kpEvent == null || kpEvent !is KeyPackageEvent) {
|
||||
val relays =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays())
|
||||
val kpEvent =
|
||||
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"))
|
||||
continue
|
||||
}
|
||||
val kpBytes = Base64.decode(kpEvent.keyPackageBase64())
|
||||
|
||||
val (commitEvent, welcomeDelivery) =
|
||||
ctx.marmot.addMember(
|
||||
nostrGroupId = gid,
|
||||
memberPubKey = pub,
|
||||
keyPackageBytes = kpBytes,
|
||||
keyPackageEventId = kpEvent.id,
|
||||
keyPackageEvent = kpEvent,
|
||||
relays = groupRelays.toList(),
|
||||
)
|
||||
|
||||
|
||||
@@ -42,16 +42,14 @@ object GroupCreateCommand {
|
||||
|
||||
ctx.marmot.createGroup(gid)
|
||||
|
||||
// Stamp initial metadata: name + our outbox relays + self as admin.
|
||||
// Mirrors CreateGroupScreen.proceedWithCreate() on the Amethyst side.
|
||||
// Stamp initial metadata via the shared factory so UI + CLI stay byte-identical.
|
||||
val outboxUrls = ctx.outboxRelays().map { it.url }
|
||||
val metadata =
|
||||
MarmotGroupData(
|
||||
MarmotGroupData.bootstrap(
|
||||
nostrGroupId = gid,
|
||||
creatorPubKey = ctx.identity.pubKeyHex,
|
||||
outboxRelays = outboxUrls,
|
||||
name = name,
|
||||
description = "",
|
||||
adminPubkeys = listOf(ctx.identity.pubKeyHex),
|
||||
relays = outboxUrls,
|
||||
)
|
||||
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.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
||||
|
||||
object GroupMembershipCommands {
|
||||
suspend fun remove(
|
||||
@@ -32,26 +31,25 @@ object GroupMembershipCommands {
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
|
||||
val gid = rest[0]
|
||||
val target = Npubs.resolveToHex(rest[1])
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val target = ctx.requireUserHex(rest[1])
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
|
||||
val member =
|
||||
ctx.marmot.memberPubkeys(gid).firstOrNull { it.pubkey == target }
|
||||
val leafIndex =
|
||||
ctx.marmot.leafIndexOf(gid, target)
|
||||
?: return Json.error("not_in_group", target)
|
||||
|
||||
val outbound =
|
||||
ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = member.leafIndex)
|
||||
val outbound = ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = leafIndex)
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val ack = ctx.publish(outbound.signedEvent, targets)
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"removed" to target,
|
||||
"leaf_index" to member.leafIndex,
|
||||
"leaf_index" to leafIndex,
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
"commit_event_id" to outbound.signedEvent.id,
|
||||
"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.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@@ -37,7 +36,7 @@ object GroupMetadataCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
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(
|
||||
@@ -45,8 +44,8 @@ object GroupMetadataCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group promote <gid> <npub>")
|
||||
val newAdmin = Npubs.resolveToHex(rest[1])
|
||||
return edit(dataDir, rest[0]) { cur ->
|
||||
return edit(dataDir, rest[0]) { ctx, cur ->
|
||||
val newAdmin = ctx.requireUserHex(rest[1])
|
||||
val admins = cur.adminPubkeys.toMutableList()
|
||||
if (newAdmin !in admins) admins.add(newAdmin)
|
||||
cur.copy(adminPubkeys = admins)
|
||||
@@ -58,8 +57,8 @@ object GroupMetadataCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group demote <gid> <npub>")
|
||||
val target = Npubs.resolveToHex(rest[1])
|
||||
return edit(dataDir, rest[0]) { cur ->
|
||||
return edit(dataDir, rest[0]) { ctx, cur ->
|
||||
val target = ctx.requireUserHex(rest[1])
|
||||
val admins = cur.adminPubkeys.filter { it != target }
|
||||
cur.copy(adminPubkeys = admins)
|
||||
}
|
||||
@@ -68,23 +67,22 @@ object GroupMetadataCommands {
|
||||
private suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
gid: HexKey,
|
||||
mutate: (MarmotGroupData) -> MarmotGroupData,
|
||||
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
|
||||
): Int {
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
val outboxUrls = ctx.outboxRelays().map { it.url }
|
||||
val cur =
|
||||
ctx.marmot.groupMetadata(gid)
|
||||
?: MarmotGroupData(
|
||||
?: MarmotGroupData.bootstrap(
|
||||
nostrGroupId = gid,
|
||||
name = "",
|
||||
description = "",
|
||||
adminPubkeys = listOf(ctx.identity.pubKeyHex),
|
||||
relays = ctx.outboxRelays().map { it.url },
|
||||
creatorPubKey = ctx.identity.pubKeyHex,
|
||||
outboxRelays = outboxUrls,
|
||||
)
|
||||
val updated = mutate(cur)
|
||||
val updated = mutate(ctx, cur).withMergedRelays(outboxUrls)
|
||||
|
||||
val commit = ctx.marmot.updateGroupMetadata(gid, updated)
|
||||
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.DataDir
|
||||
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 {
|
||||
suspend fun dispatch(
|
||||
@@ -68,16 +65,20 @@ object KeyPackageCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "key-package check <npub>")
|
||||
val targetHex = Npubs.resolveToHex(rest[0])
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(targetHex)
|
||||
val relays = ctx.anyRelays()
|
||||
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.
|
||||
val relays =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays())
|
||||
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
|
||||
val filtersByRelay = relays.associateWith { listOf(filter) }
|
||||
val event = ctx.client.fetchFirst(filters = filtersByRelay, timeoutMs = 10_000)
|
||||
if (event == null || event !is KeyPackageEvent) {
|
||||
val event =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.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)")
|
||||
}
|
||||
Json.writeLine(
|
||||
|
||||
@@ -25,7 +25,6 @@ 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.nip01Core.signers.eventTemplate
|
||||
|
||||
object MessageCommands {
|
||||
suspend fun dispatch(
|
||||
@@ -54,24 +53,16 @@ object MessageCommands {
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
|
||||
val template =
|
||||
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 bundle = ctx.marmot.buildTextMessage(gid, text)
|
||||
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(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"inner_event_id" to innerEvent.id,
|
||||
"outer_event_id" to outbound.signedEvent.id,
|
||||
"kind" to innerEvent.kind,
|
||||
"inner_event_id" to bundle.innerEvent.id,
|
||||
"outer_event_id" to bundle.outbound.signedEvent.id,
|
||||
"kind" to bundle.innerEvent.kind,
|
||||
"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,
|
||||
): 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.
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* MLS-Exporter("marmot", "encrypted-media", 32)
|
||||
@@ -477,3 +540,13 @@ data class GroupMemberInfo(
|
||||
val leafIndex: Int,
|
||||
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 */
|
||||
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.
|
||||
* Mirrors the [decodeTls] format.
|
||||
@@ -185,6 +200,30 @@ data class MarmotGroupData(
|
||||
const val EXTENSION_ID: UShort = 0xF2EEu
|
||||
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.
|
||||
* 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