Merge pull request #2147 from vitorpamplona/claude/marmot-protocol-integration-xb3TT

Add Marmot MLS group messaging support to Amethyst
This commit is contained in:
Vitor Pamplona
2026-04-05 16:06:12 -04:00
committed by GitHub
12 changed files with 732 additions and 0 deletions
@@ -339,6 +339,7 @@ class AppModules(
otsResolverBuilder = { otsResolverBuilder.build() },
cache = cache,
client = client,
rootFilesDir = { appContext.filesDir },
)
val sessionManager =
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
@@ -129,6 +130,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension
import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent
import com.vitorpamplona.quartz.experimental.profileGallery.hash
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -251,6 +253,7 @@ class Account(
val cache: LocalCache,
val client: INostrClient,
val scope: CoroutineScope,
val mlsGroupStateStore: MlsGroupStateStore? = null,
) : IAccount {
private var userProfileCache: User? = null
@@ -378,6 +381,8 @@ class Account(
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it) }
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
val feedDecryptionCaches =
@@ -1704,6 +1709,98 @@ class Account(
}
}
// --- Marmot Group Messaging ---
/**
* Send a message to a Marmot MLS group.
* Encrypts the inner event and publishes the GroupEvent to group relays.
*/
suspend fun sendMarmotGroupMessage(
nostrGroupId: HexKey,
innerEvent: Event,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = marmotManager ?: return
if (!isWriteable()) return
val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent)
cache.justConsumeMyOwnEvent(outbound.signedEvent)
client.publish(outbound.signedEvent, groupRelays)
}
/**
* Add a member to a Marmot MLS group.
* Publishes the commit GroupEvent, then sends the Welcome gift wrap.
*/
suspend fun addMarmotGroupMember(
nostrGroupId: HexKey,
memberPubKey: HexKey,
keyPackageBytes: ByteArray,
keyPackageEventId: HexKey,
groupRelays: List<NormalizedRelayUrl>,
) {
val manager = marmotManager ?: return
if (!isWriteable()) return
val (commitEvent, welcomeDelivery) =
manager.addMember(
nostrGroupId = nostrGroupId,
memberPubKey = memberPubKey,
keyPackageBytes = keyPackageBytes,
keyPackageEventId = keyPackageEventId,
relays = groupRelays,
)
// Publish commit first (critical ordering)
client.publish(commitEvent.signedEvent, groupRelays.toSet())
// Then send Welcome gift wrap to the new member
if (welcomeDelivery != null) {
val relayList = computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)
client.publish(welcomeDelivery.giftWrapEvent, relayList)
}
}
/**
* Publish or rotate KeyPackage events.
*/
suspend fun publishMarmotKeyPackages() {
val manager = marmotManager ?: return
if (!isWriteable()) return
val relays = outboxRelays.flow.value.toList()
if (manager.needsKeyPackageRotation()) {
val rotatedEvents = manager.rotateConsumedKeyPackages(relays)
rotatedEvents.forEach { event ->
cache.justConsumeMyOwnEvent(event)
client.publish(event, outboxRelays.flow.value)
}
}
}
/**
* Generate and publish initial KeyPackage for this account.
*/
suspend fun publishMarmotKeyPackage() {
val manager = marmotManager ?: return
if (!isWriteable()) return
val relays = outboxRelays.flow.value.toList()
val event = manager.generateKeyPackageEvent(relays)
cache.justConsumeMyOwnEvent(event)
client.publish(event, outboxRelays.flow.value)
}
/**
* Create a new Marmot MLS group.
*/
suspend fun createMarmotGroup(nostrGroupId: HexKey) {
val manager = marmotManager ?: return
if (!isWriteable()) return
manager.createGroup(nostrGroupId)
}
suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) {
@@ -2162,6 +2259,13 @@ class Account(
init {
Log.d("AccountRegisterObservers", "Init")
// Restore Marmot MLS group state on startup
if (marmotManager != null) {
scope.launch(Dispatchers.IO) {
marmotManager.restoreAll()
}
}
scope.launch {
cache.antiSpam.flowSpam.collect {
it.cache.spamMessages.snapshot().values.forEach { spammer ->
@@ -63,6 +63,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -2613,6 +2615,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is GoalEvent -> consumeRegularEvent(event, relay, wasVerified)
is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified)
is GroupEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
is GitPatchEvent -> consumeRegularEvent(event, relay, wasVerified)
@@ -2622,6 +2625,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is ChessGameEvent -> consumeRegularEvent(event, relay, wasVerified)
is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is JesterEvent -> consumeRegularEvent(event, relay, wasVerified)
is KeyPackageEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified)
@@ -24,6 +24,7 @@ import android.content.ContentResolver
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -43,6 +44,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.io.File
class AccountCacheState(
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
@@ -51,6 +53,7 @@ class AccountCacheState(
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
val client: INostrClient,
val rootFilesDir: () -> File = { File("") },
) {
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
@@ -94,6 +97,13 @@ class AccountCacheState(
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
val mlsStore =
try {
AndroidMlsGroupStateStore(rootFilesDir())
} catch (_: Exception) {
null
}
return Account(
settings = accountSettings,
signer = signerWithClientTag,
@@ -110,6 +120,7 @@ class AccountCacheState(
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception: ${throwable.message}", throwable)
},
),
mlsGroupStateStore = mlsStore,
).also { newAccount ->
accounts.update { existingAccounts ->
existingAccounts.plus(Pair(signer.pubKey, newAccount))
@@ -0,0 +1,147 @@
/*
* 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.model.marmot
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Android implementation of [MlsGroupStateStore] using file-based encrypted storage.
*
* All MLS group state (containing private keys and epoch secrets) is encrypted
* at rest using [KeyStoreEncryption] (AES/GCM backed by Android KeyStore).
*
* Storage layout:
* ```
* <rootDir>/mls_groups/<nostrGroupId>/state encrypted MlsGroupState
* <rootDir>/mls_groups/<nostrGroupId>/retained encrypted retained epoch secrets
* ```
*/
class AndroidMlsGroupStateStore(
private val rootDir: File,
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
) : MlsGroupStateStore {
private fun groupDir(nostrGroupId: String): File = File(rootDir, "mls_groups/$nostrGroupId")
private fun stateFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "state")
private fun retainedFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "retained")
override suspend fun save(
nostrGroupId: String,
state: ByteArray,
) = withContext(Dispatchers.IO) {
val file = stateFile(nostrGroupId)
file.parentFile?.mkdirs()
file.writeBytes(encryption.encrypt(state))
}
override suspend fun load(nostrGroupId: String): ByteArray? =
withContext(Dispatchers.IO) {
val file = stateFile(nostrGroupId)
if (!file.exists()) return@withContext null
encryption.decrypt(file.readBytes())
}
override suspend fun delete(nostrGroupId: String) {
withContext(Dispatchers.IO) {
val dir = groupDir(nostrGroupId)
dir.deleteRecursively()
}
}
override suspend fun listGroups(): List<String> =
withContext(Dispatchers.IO) {
val baseDir = File(rootDir, "mls_groups")
if (!baseDir.exists()) return@withContext emptyList()
baseDir
.listFiles()
?.filter { it.isDirectory && File(it, "state").exists() }
?.map { it.name }
?: emptyList()
}
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) = withContext(Dispatchers.IO) {
val file = retainedFile(nostrGroupId)
file.parentFile?.mkdirs()
// Format: 4-byte count, then for each: 4-byte length + data
val totalSize = 4 + retainedSecrets.sumOf { 4 + it.size }
val buffer = ByteArray(totalSize)
var offset = 0
// Write count
val count = retainedSecrets.size
buffer[offset++] = (count shr 24).toByte()
buffer[offset++] = (count shr 16).toByte()
buffer[offset++] = (count shr 8).toByte()
buffer[offset++] = count.toByte()
// Write each entry
for (secret in retainedSecrets) {
val len = secret.size
buffer[offset++] = (len shr 24).toByte()
buffer[offset++] = (len shr 16).toByte()
buffer[offset++] = (len shr 8).toByte()
buffer[offset++] = len.toByte()
secret.copyInto(buffer, offset)
offset += len
}
file.writeBytes(encryption.encrypt(buffer))
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> =
withContext(Dispatchers.IO) {
val file = retainedFile(nostrGroupId)
if (!file.exists()) return@withContext emptyList()
val buffer = encryption.decrypt(file.readBytes()) ?: return@withContext emptyList()
if (buffer.size < 4) return@withContext emptyList()
var offset = 0
val count =
((buffer[offset++].toInt() and 0xFF) shl 24) or
((buffer[offset++].toInt() and 0xFF) shl 16) or
((buffer[offset++].toInt() and 0xFF) shl 8) or
(buffer[offset++].toInt() and 0xFF)
val result = mutableListOf<ByteArray>()
for (i in 0 until count) {
if (offset + 4 > buffer.size) break
val len =
((buffer[offset++].toInt() and 0xFF) shl 24) or
((buffer[offset++].toInt() and 0xFF) shl 16) or
((buffer[offset++].toInt() and 0xFF) shl 8) or
(buffer[offset++].toInt() and 0xFF)
if (offset + len > buffer.size) break
result.add(buffer.copyOfRange(offset, offset + len))
offset += len
}
result
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.AccountFollowsLoaderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager
@@ -64,6 +65,7 @@ class AccountFilterAssembler(
AccountDraftsEoseManager(client, ::allKeys),
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys),
MarmotGroupEventsEoseManager(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
@@ -0,0 +1,96 @@
/*
* 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.service.relayClient.reqCommand.account.marmot
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
* EOSE manager for Marmot GroupEvent (kind:445) subscriptions.
*
* Uses filters from [MarmotSubscriptionManager.buildFilters()] which
* include per-group kind:445 filters. These filters are sent to the
* group's relays on connect/reconnect and when groups are added/removed.
*/
class MarmotGroupEventsEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val manager = key.account.marmotManager ?: return emptyList()
if (!key.account.isWriteable()) return emptyList()
// Build Marmot filters (kind:445 per group)
val marmotFilters = manager.subscriptionManager.activeGroupFilters()
if (marmotFilters.isEmpty()) return emptyList()
// Send to the home relays (where group events are relayed)
val relays = key.account.homeRelays.flow.value
if (relays.isEmpty()) return emptyList()
return relays.flatMap { relay ->
marmotFilters.map { filter ->
RelayBasedFilter(
relay = relay,
filter = filter,
)
}
}
}
val userJobMap = mutableMapOf<User, List<Job>>()
override fun newSub(key: AccountQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.IO) {
key.account.homeRelays.flow.collect {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -26,6 +26,11 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
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.IEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
@@ -59,6 +64,8 @@ class EventProcessor(
private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache)
private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache)
private val groupEventHandler = GroupEventHandler(account, cache)
var callManager: CallManager? = null
suspend fun consume(note: Note) {
@@ -89,6 +96,8 @@ class EventProcessor(
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
is GroupEvent -> groupEventHandler.add(event, eventNote, publicNote)
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
@@ -287,6 +296,13 @@ class GiftWrapEventHandler(
val innerGift = event.unwrapOrNull(account.signer) ?: return
eventNote.event = event.copyNoContent()
// Check if the unwrapped event is a Marmot WelcomeEvent (kind:444)
if (MarmotInboundProcessor.isWelcomeEvent(innerGift)) {
processMarmotWelcome(innerGift, eventNote, publicNote)
return
}
if (cache.justConsume(innerGift, null, false)) {
cache.copyRelaysFromTo(publicNote, innerGift)
val innerGiftNote = cache.getOrCreateNote(innerGift.id)
@@ -294,6 +310,34 @@ class GiftWrapEventHandler(
}
}
private suspend fun processMarmotWelcome(
innerEvent: Event,
eventNote: Note,
publicNote: Note,
) {
val manager = account.marmotManager ?: return
if (innerEvent !is WelcomeEvent) return
val nostrGroupId = innerEvent.nostrGroupId() ?: return
val result = manager.processWelcome(innerEvent, nostrGroupId)
when (result) {
is WelcomeResult.Joined -> {
Log.d("GiftWrapEventHandler", "Joined Marmot group ${result.nostrGroupId}")
// Rotate KeyPackages if needed
if (result.needsKeyPackageRotation) {
account.publishMarmotKeyPackages()
}
}
is WelcomeResult.Error -> {
Log.w("GiftWrapEventHandler") { "Failed to process Marmot Welcome: ${result.message}" }
}
}
}
private suspend fun processExistingGiftWrap(
innerGiftId: String,
publicNote: Note,
@@ -400,3 +444,55 @@ class LnZapEventHandler(
}
}
}
/**
* Handles inbound GroupEvent (kind:445) for Marmot MLS group messaging.
*
* Decrypts the outer ChaCha20-Poly1305 layer and the inner MLS layer,
* then indexes the resulting inner event in LocalCache.
*/
class GroupEventHandler(
private val account: Account,
private val cache: LocalCache,
) : EventHandler<GroupEvent> {
override suspend fun add(
event: GroupEvent,
eventNote: Note,
publicNote: Note,
) {
val manager = account.marmotManager ?: return
val groupId = event.groupId() ?: return
if (!manager.isMember(groupId)) return
try {
val result = manager.processGroupEvent(event)
when (result) {
is GroupEventResult.ApplicationMessage -> {
// Parse the inner event JSON and index it
val innerEvent = Event.fromJson(result.innerEventJson)
if (cache.justConsume(innerEvent, null, false)) {
val innerNote = cache.getOrCreateNote(innerEvent.id)
innerNote.event = innerEvent
}
}
is GroupEventResult.CommitProcessed -> {
Log.d("GroupEventHandler", "Commit processed for group ${result.groupId}, epoch=${result.newEpoch}")
}
is GroupEventResult.CommitPending -> {
Log.d("GroupEventHandler", "Commit pending for group ${result.groupId}, epoch=${result.epoch}")
}
is GroupEventResult.Error -> {
Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" }
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("GroupEventHandler", "Failed to process GroupEvent", e)
}
}
}
@@ -0,0 +1,257 @@
/*
* 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.MarmotOutboundProcessor
import com.vitorpamplona.quartz.marmot.MarmotSubscriptionManager
import com.vitorpamplona.quartz.marmot.MarmotWelcomeSender
import com.vitorpamplona.quartz.marmot.OutboundGroupEvent
import com.vitorpamplona.quartz.marmot.WelcomeDelivery
import com.vitorpamplona.quartz.marmot.WelcomeResult
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Central coordinator for Marmot MLS group messaging.
*
* Holds all Marmot components and provides high-level operations for:
* - Group lifecycle (create, join, leave)
* - Message sending and receiving
* - KeyPackage management and rotation
* - Subscription filter coordination
*
* Initialized during Account startup; all methods should be called
* from the Account's coroutine scope.
*/
class MarmotManager(
val signer: NostrSigner,
store: MlsGroupStateStore,
) {
val groupManager = MlsGroupManager(store)
val keyPackageRotationManager = KeyPackageRotationManager()
val subscriptionManager = MarmotSubscriptionManager(signer.pubKey)
val inboundProcessor = MarmotInboundProcessor(groupManager, keyPackageRotationManager)
val outboundProcessor = MarmotOutboundProcessor(groupManager)
val welcomeSender = MarmotWelcomeSender(signer)
/**
* Restore all Marmot state from persistent storage.
* Call once during Account initialization.
*/
suspend fun restoreAll() {
try {
groupManager.restoreAll()
subscriptionManager.syncWithGroupManager(groupManager.activeGroupIds())
Log.d("MarmotManager", "Restored ${groupManager.activeGroupIds().size} groups")
} catch (e: Exception) {
Log.e("MarmotManager", "Failed to restore Marmot state", e)
}
}
// --- Inbound Processing ---
/**
* Process an inbound GroupEvent (kind:445).
* Returns the inner event JSON if it was an application message.
*/
suspend fun processGroupEvent(groupEvent: GroupEvent): GroupEventResult {
val result = inboundProcessor.processGroupEvent(groupEvent)
// Update subscription timestamp
when (result) {
is GroupEventResult.ApplicationMessage -> {
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
}
is GroupEventResult.CommitProcessed -> {
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
}
is GroupEventResult.CommitPending,
is GroupEventResult.Error,
-> {}
}
return result
}
/**
* Process a WelcomeEvent (kind:444) after NIP-59 unwrapping.
* Returns the result including whether KeyPackage rotation is needed.
*/
suspend fun processWelcome(
welcomeEvent: WelcomeEvent,
nostrGroupId: HexKey,
): WelcomeResult {
val result = inboundProcessor.processWelcome(welcomeEvent, nostrGroupId)
if (result is WelcomeResult.Joined) {
// Update subscription state for the new group
subscriptionManager.subscribeGroup(result.nostrGroupId)
Log.d("MarmotManager", "Joined group ${result.nostrGroupId}")
}
return result
}
// --- Outbound Operations ---
/**
* Build a GroupEvent for sending a message to a group.
*/
suspend fun buildGroupMessage(
nostrGroupId: HexKey,
innerEvent: Event,
): OutboundGroupEvent = outboundProcessor.buildGroupEvent(nostrGroupId, innerEvent)
/**
* Add a member to a group.
* Returns the commit GroupEvent to publish, and the WelcomeDelivery for the new member.
*/
suspend fun addMember(
nostrGroupId: HexKey,
memberPubKey: HexKey,
keyPackageBytes: ByteArray,
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
): Pair<OutboundGroupEvent, WelcomeDelivery?> {
val commitResult = groupManager.addMember(nostrGroupId, keyPackageBytes)
val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes)
val welcomeDelivery =
welcomeSender.wrapWelcome(
commitResult = commitResult,
recipientPubKey = memberPubKey,
keyPackageEventId = keyPackageEventId,
relays = relays,
nostrGroupId = nostrGroupId,
)
return Pair(commitEvent, welcomeDelivery)
}
/**
* Create a new MLS group.
*/
suspend fun createGroup(nostrGroupId: HexKey): HexKey {
val identity = signer.pubKey.hexToByteArray()
groupManager.createGroup(nostrGroupId, identity)
subscriptionManager.subscribeGroup(nostrGroupId)
return nostrGroupId
}
/**
* Leave a group.
* Returns proposal bytes to publish (as a GroupEvent).
*/
suspend fun leaveGroup(nostrGroupId: HexKey): OutboundGroupEvent {
val proposalBytes = groupManager.leaveGroup(nostrGroupId)
subscriptionManager.unsubscribeGroup(nostrGroupId)
return outboundProcessor.buildCommitEvent(nostrGroupId, proposalBytes)
}
// --- KeyPackage Management ---
/**
* Generate and build a KeyPackage event template for publishing.
* Returns the signed KeyPackageEvent ready for publishing.
*/
@OptIn(ExperimentalEncodingApi::class)
suspend fun generateKeyPackageEvent(
relays: List<NormalizedRelayUrl>,
dTagSlot: String = KeyPackageUtils.PRIMARY_SLOT,
): KeyPackageEvent {
val identity = signer.pubKey.hexToByteArray()
val bundle = keyPackageRotationManager.generateKeyPackage(identity, dTagSlot)
val keyPackageBytes = bundle.keyPackage.toTlsBytes()
val keyPackageBase64 = Base64.encode(keyPackageBytes)
val keyPackageRef = bundle.keyPackage.reference().toHexKey()
val template =
KeyPackageEvent.build(
keyPackageBase64 = keyPackageBase64,
dTagSlot = dTagSlot,
keyPackageRef = keyPackageRef,
relays = relays,
)
return signer.sign(template)
}
/**
* Rotate consumed KeyPackage slots.
* Returns list of KeyPackageEvents to publish.
*/
@OptIn(ExperimentalEncodingApi::class)
suspend fun rotateConsumedKeyPackages(relays: List<NormalizedRelayUrl>): List<KeyPackageEvent> {
val pendingSlots = keyPackageRotationManager.pendingRotationSlots()
if (pendingSlots.isEmpty()) return emptyList()
val identity = signer.pubKey.hexToByteArray()
return pendingSlots.map { slot ->
val bundle = keyPackageRotationManager.rotateSlot(identity, slot)
val keyPackageBytes = bundle.keyPackage.toTlsBytes()
val keyPackageBase64 = Base64.encode(keyPackageBytes)
val keyPackageRef = bundle.keyPackage.reference().toHexKey()
val template =
KeyPackageEvent.build(
keyPackageBase64 = keyPackageBase64,
dTagSlot = slot,
keyPackageRef = keyPackageRef,
relays = relays,
)
signer.sign<KeyPackageEvent>(template)
}
}
/**
* Check if KeyPackage rotation is needed.
*/
fun needsKeyPackageRotation(): Boolean = keyPackageRotationManager.needsRotation()
/**
* Check if a specific group membership exists.
*/
fun isMember(nostrGroupId: HexKey): Boolean = groupManager.isMember(nostrGroupId)
/**
* Get all active group IDs.
*/
fun activeGroupIds(): Set<HexKey> = groupManager.activeGroupIds()
}
@@ -71,6 +71,7 @@ class MarmotWelcomeSender(
recipientPubKey: HexKey,
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
nostrGroupId: HexKey? = null,
): WelcomeDelivery? {
val welcomeBytes = commitResult.welcomeBytes ?: return null
@@ -83,6 +84,7 @@ class MarmotWelcomeSender(
relays = relays,
recipientPubKey = recipientPubKey,
signer = signer,
nostrGroupId = nostrGroupId,
)
return WelcomeDelivery(
@@ -109,6 +111,7 @@ class MarmotWelcomeSender(
recipientPubKey: HexKey,
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
nostrGroupId: HexKey? = null,
): WelcomeDelivery {
val welcomeBase64 = Base64.encode(welcomeBytes)
@@ -119,6 +122,7 @@ class MarmotWelcomeSender(
relays = relays,
recipientPubKey = recipientPubKey,
signer = signer,
nostrGroupId = nostrGroupId,
)
return WelcomeDelivery(
@@ -67,6 +67,12 @@ class WelcomeEvent(
/** Content encoding (must be "base64") */
fun encoding() = tags.welcomeEncoding()
/** Nostr group ID from the "h" tag (for recipient to identify the group) */
fun nostrGroupId(): HexKey? =
tags.firstNotNullOfOrNull { tag ->
if (tag.size >= 2 && tag[0] == "h") tag[1] else null
}
override fun isContentEncoded() = true
companion object {
@@ -77,12 +83,14 @@ class WelcomeEvent(
welcomeBase64: String,
keyPackageEventId: HexKey,
relays: List<NormalizedRelayUrl>,
nostrGroupId: HexKey? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<WelcomeEvent>.() -> Unit = {},
) = eventTemplate(KIND, welcomeBase64, createdAt) {
keyPackageEventId(keyPackageEventId)
welcomeRelays(relays)
encoding()
nostrGroupId?.let { addUnique(arrayOf("h", it)) }
initializer()
}
}
@@ -57,6 +57,7 @@ object WelcomeGiftWrap {
relays: List<NormalizedRelayUrl>,
recipientPubKey: HexKey,
signer: NostrSigner,
nostrGroupId: HexKey? = null,
createdAt: Long = TimeUtils.now(),
): GiftWrapEvent {
// Step 1: Build the WelcomeEvent template and sign it
@@ -65,6 +66,7 @@ object WelcomeGiftWrap {
welcomeBase64 = welcomeBase64,
keyPackageEventId = keyPackageEventId,
relays = relays,
nostrGroupId = nostrGroupId,
createdAt = createdAt,
)
val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate)