Merge pull request #2488 from vitorpamplona/claude/debug-marmot-whitenoise-oO26C
Add CLI interface (amy) for Marmot/MLS group operations
This commit is contained in:
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultChannels
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultGlobalRelays
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Full set of "new Amethyst account" bootstrap events: the nine signed
|
||||
* events that [com.vitorpamplona.amethyst.ui.screen.AccountSessionManager.createNewAccount]
|
||||
* produces for a brand-new user, in the exact same order and with the same
|
||||
* relay defaults. Used by:
|
||||
*
|
||||
* - Amethyst's in-app "Create Account" flow (builds an `AccountSettings`
|
||||
* from these).
|
||||
* - `amy create` (writes the keypair to disk, publishes these to the
|
||||
* default NIP-65 relay set).
|
||||
*
|
||||
* Each event is optional in the sense that the builder lets you null out
|
||||
* individual ones via the args — but the sensible default is "all of them".
|
||||
*/
|
||||
data class AccountBootstrapEvents(
|
||||
val userMetadata: MetadataEvent,
|
||||
val contactList: ContactListEvent,
|
||||
val nip65RelayList: AdvertisedRelayListEvent,
|
||||
val dmRelayList: ChatMessageRelayListEvent,
|
||||
val keyPackageRelayList: KeyPackageRelayListEvent,
|
||||
val searchRelayList: SearchRelayListEvent,
|
||||
val indexerRelayList: IndexerRelayListEvent,
|
||||
val channelList: ChannelListEvent,
|
||||
val relayFeedsList: RelayFeedsListEvent,
|
||||
) {
|
||||
/** All nine signed events in publication order. */
|
||||
fun all(): List<com.vitorpamplona.quartz.nip01Core.core.Event> =
|
||||
listOf(
|
||||
userMetadata,
|
||||
contactList,
|
||||
nip65RelayList,
|
||||
dmRelayList,
|
||||
keyPackageRelayList,
|
||||
searchRelayList,
|
||||
indexerRelayList,
|
||||
channelList,
|
||||
relayFeedsList,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and sign the nine events that comprise a freshly-created Amethyst
|
||||
* account. The caller is responsible for publishing them — typically to
|
||||
* [DefaultNIP65RelaySet] — after at least a short delay so relay
|
||||
* connections are established.
|
||||
*
|
||||
* [name] is the optional display name; null or blank produces a kind:0
|
||||
* event with no `name` field.
|
||||
*/
|
||||
fun bootstrapAccountEvents(
|
||||
signer: NostrSignerSync,
|
||||
name: String? = null,
|
||||
): AccountBootstrapEvents =
|
||||
AccountBootstrapEvents(
|
||||
userMetadata = signer.sign(MetadataEvent.newUser(name)),
|
||||
contactList =
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = listOf(ContactTag(signer.keyPair.pubKey.toHexKey(), null, null)),
|
||||
relayUse = emptyMap(),
|
||||
signer = signer,
|
||||
),
|
||||
nip65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, signer),
|
||||
dmRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, signer),
|
||||
// MIP-00: advertise the default outbox relays as KeyPackage hosts
|
||||
// so other users can discover and fetch this account's KeyPackage
|
||||
// events without having to guess where they were published.
|
||||
keyPackageRelayList = KeyPackageRelayListEvent.create(DefaultNIP65RelaySet.toList(), signer),
|
||||
searchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), signer),
|
||||
indexerRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), signer),
|
||||
channelList = ChannelListEvent.create(emptyList(), DefaultChannels, signer),
|
||||
relayFeedsList = RelayFeedsListEvent.create(DefaultGlobalRelays, signer),
|
||||
)
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.defaults
|
||||
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
|
||||
|
||||
/**
|
||||
* Default relay/channel bundles applied to every freshly-created Amethyst
|
||||
* account. Both the Android UI (`AccountSessionManager.createNewAccount`)
|
||||
* and the `amy` CLI (`amy create`) seed new accounts from these so users
|
||||
* land in the same connected state regardless of which client they start
|
||||
* with.
|
||||
*
|
||||
* Pure data — no platform deps, no runtime config, no i18n. Change here,
|
||||
* both clients follow.
|
||||
*/
|
||||
|
||||
val DefaultChannels =
|
||||
listOf(
|
||||
// Anigma's Nostr
|
||||
ChannelTag("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos),
|
||||
// Amethyst's Group
|
||||
ChannelTag("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos),
|
||||
)
|
||||
|
||||
val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner)
|
||||
|
||||
val DefaultNIP65List =
|
||||
listOf(
|
||||
AdvertisedRelayInfo(Constants.mom, AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayInfo(Constants.nos, AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH),
|
||||
)
|
||||
|
||||
val DefaultGlobalRelays = listOf(Constants.wine, Constants.news)
|
||||
|
||||
val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
|
||||
|
||||
val DefaultSearchRelayList =
|
||||
setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto)
|
||||
|
||||
val DefaultIndexerRelayList =
|
||||
setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1)
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.defaults
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
/**
|
||||
* Relay URLs that Amethyst (and `amy`) seed new accounts with and use as
|
||||
* sensible defaults across the app. Pure data — lives in commons so JVM /
|
||||
* iOS / desktop builds get the same URLs without duplicating the list.
|
||||
*/
|
||||
object Constants {
|
||||
val nos = RelayUrlNormalizer.normalize("wss://nos.lol")
|
||||
val mom = RelayUrlNormalizer.normalize("wss://nostr.mom")
|
||||
val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net")
|
||||
val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io")
|
||||
val wine = RelayUrlNormalizer.normalize("wss://nostr.wine")
|
||||
|
||||
val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com")
|
||||
val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org")
|
||||
|
||||
val bitcoiner = RelayUrlNormalizer.normalize("wss://nostr.bitcoiner.social")
|
||||
val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev")
|
||||
|
||||
val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today")
|
||||
val antiprimal = RelayUrlNormalizer.normalize("wss://antiprimal.net")
|
||||
val ditto = RelayUrlNormalizer.normalize("wss://relay.ditto.pub")
|
||||
|
||||
val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com")
|
||||
val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com")
|
||||
|
||||
val news = RelayUrlNormalizer.normalize("wss://news.utxo.one")
|
||||
|
||||
val purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es")
|
||||
val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social")
|
||||
val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es")
|
||||
val yabu = RelayUrlNormalizer.normalize("wss://directory.yabu.me")
|
||||
val nostr1 = RelayUrlNormalizer.normalize("wss://profiles.nostr1.com")
|
||||
|
||||
val bootstrapInbox = setOf(damus, primal, mom, nos, bitcoiner, oxtr, yabu)
|
||||
val eventFinderRelays = setOf(wine, damus, primal, mom, nos, bitcoiner, oxtr)
|
||||
}
|
||||
+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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user