feat: Phase 6 — Marmot account integration & event routing
Wire Marmot MLS group messaging into the app's Account lifecycle, event processing pipeline, and relay subscription system. New files: - AndroidMlsGroupStateStore: encrypted file-based MLS state storage using KeyStoreEncryption (AES/GCM backed by Android KeyStore) - MarmotManager: central coordinator holding all Marmot components (MlsGroupManager, KeyPackageRotationManager, subscription/inbound/ outbound processors, WelcomeSender) - MarmotGroupEventsEoseManager: relay subscription manager for kind:445 GroupEvent filters via AccountFilterAssembler Account integration: - MarmotManager initialized during Account startup with restoreAll() - Outbound methods: sendMarmotGroupMessage, addMarmotGroupMember, publishMarmotKeyPackage(s), createMarmotGroup - GroupEvent (kind:445) and KeyPackageEvent (kind:30443) added to LocalCache.justConsumeInnerInner() dispatch Event routing: - GroupEventHandler: processes inbound kind:445 events through MarmotInboundProcessor, indexes decrypted inner events in LocalCache - GiftWrapEventHandler extended: detects kind:444 WelcomeEvent after NIP-59 unwrap, routes to MarmotManager.processWelcome(), triggers KeyPackage rotation when needed - WelcomeEvent enhanced with optional "h" tag for group ID routing https://claude.ai/code/session_01W2LHazEt4E3W4hn8f7gWVW
This commit is contained in:
@@ -339,6 +339,7 @@ class AppModules(
|
|||||||
otsResolverBuilder = { otsResolverBuilder.build() },
|
otsResolverBuilder = { otsResolverBuilder.build() },
|
||||||
cache = cache,
|
cache = cache,
|
||||||
client = client,
|
client = client,
|
||||||
|
rootFilesDir = { appContext.filesDir },
|
||||||
)
|
)
|
||||||
|
|
||||||
val sessionManager =
|
val sessionManager =
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
|
|||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.BuildConfig
|
import com.vitorpamplona.amethyst.BuildConfig
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
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.IAccount
|
||||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
|
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.fromEvent
|
||||||
import com.vitorpamplona.quartz.experimental.profileGallery.hash
|
import com.vitorpamplona.quartz.experimental.profileGallery.hash
|
||||||
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType
|
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.AddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
@@ -251,6 +253,7 @@ class Account(
|
|||||||
val cache: LocalCache,
|
val cache: LocalCache,
|
||||||
val client: INostrClient,
|
val client: INostrClient,
|
||||||
val scope: CoroutineScope,
|
val scope: CoroutineScope,
|
||||||
|
val mlsGroupStateStore: MlsGroupStateStore? = null,
|
||||||
) : IAccount {
|
) : IAccount {
|
||||||
private var userProfileCache: User? = null
|
private var userProfileCache: User? = null
|
||||||
|
|
||||||
@@ -378,6 +381,8 @@ class Account(
|
|||||||
|
|
||||||
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
|
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
|
||||||
|
|
||||||
|
val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it) }
|
||||||
|
|
||||||
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
|
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
|
||||||
|
|
||||||
val feedDecryptionCaches =
|
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 createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
|
||||||
|
|
||||||
suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) {
|
suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) {
|
||||||
@@ -2162,6 +2259,13 @@ class Account(
|
|||||||
init {
|
init {
|
||||||
Log.d("AccountRegisterObservers", "Init")
|
Log.d("AccountRegisterObservers", "Init")
|
||||||
|
|
||||||
|
// Restore Marmot MLS group state on startup
|
||||||
|
if (marmotManager != null) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
marmotManager.restoreAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
cache.antiSpam.flowSpam.collect {
|
cache.antiSpam.flowSpam.collect {
|
||||||
it.cache.spamMessages.snapshot().values.forEach { spammer ->
|
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.nns.NNSEvent
|
||||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
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.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
@@ -2613,6 +2615,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is GoalEvent -> consumeRegularEvent(event, relay, wasVerified)
|
is GoalEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||||
is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified)
|
is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||||
|
is GroupEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||||
is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified)
|
is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||||
is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
|
is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||||
is GitPatchEvent -> 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 ChessGameEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||||
is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is JesterEvent -> consumeRegularEvent(event, relay, wasVerified)
|
is JesterEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||||
|
is KeyPackageEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
|
|||||||
+11
@@ -24,6 +24,7 @@ import android.content.ContentResolver
|
|||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
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.location.LocationState
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class AccountCacheState(
|
class AccountCacheState(
|
||||||
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
|
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
|
||||||
@@ -51,6 +53,7 @@ class AccountCacheState(
|
|||||||
val otsResolverBuilder: () -> OtsResolver,
|
val otsResolverBuilder: () -> OtsResolver,
|
||||||
val cache: LocalCache,
|
val cache: LocalCache,
|
||||||
val client: INostrClient,
|
val client: INostrClient,
|
||||||
|
val rootFilesDir: () -> File = { File("") },
|
||||||
) {
|
) {
|
||||||
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
|
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
|
||||||
|
|
||||||
@@ -94,6 +97,13 @@ class AccountCacheState(
|
|||||||
|
|
||||||
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
|
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
|
||||||
|
|
||||||
|
val mlsStore =
|
||||||
|
try {
|
||||||
|
AndroidMlsGroupStateStore(rootFilesDir())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
return Account(
|
return Account(
|
||||||
settings = accountSettings,
|
settings = accountSettings,
|
||||||
signer = signerWithClientTag,
|
signer = signerWithClientTag,
|
||||||
@@ -110,6 +120,7 @@ class AccountCacheState(
|
|||||||
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception: ${throwable.message}", throwable)
|
Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception: ${throwable.message}", throwable)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
mlsGroupStateStore = mlsStore,
|
||||||
).also { newAccount ->
|
).also { newAccount ->
|
||||||
accounts.update { existingAccounts ->
|
accounts.update { existingAccounts ->
|
||||||
existingAccounts.plus(Pair(signer.pubKey, newAccount))
|
existingAccounts.plus(Pair(signer.pubKey, newAccount))
|
||||||
|
|||||||
+147
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Account
|
|||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager
|
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.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.metadata.AccountMetadataEoseManager
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager
|
||||||
@@ -64,6 +65,7 @@ class AccountFilterAssembler(
|
|||||||
AccountDraftsEoseManager(client, ::allKeys),
|
AccountDraftsEoseManager(client, ::allKeys),
|
||||||
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
|
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
|
||||||
AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys),
|
AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys),
|
||||||
|
MarmotGroupEventsEoseManager(client, ::allKeys),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun invalidateKeys() = invalidateFilters()
|
override fun invalidateKeys() = invalidateFilters()
|
||||||
|
|||||||
+96
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -26,6 +26,11 @@ import com.vitorpamplona.amethyst.model.Account
|
|||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
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.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.IEvent
|
import com.vitorpamplona.quartz.nip01Core.core.IEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||||
@@ -59,6 +64,8 @@ class EventProcessor(
|
|||||||
private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache)
|
private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache)
|
||||||
private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache)
|
private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache)
|
||||||
|
|
||||||
|
private val groupEventHandler = GroupEventHandler(account, cache)
|
||||||
|
|
||||||
var callManager: CallManager? = null
|
var callManager: CallManager? = null
|
||||||
|
|
||||||
suspend fun consume(note: Note) {
|
suspend fun consume(note: Note) {
|
||||||
@@ -89,6 +96,8 @@ class EventProcessor(
|
|||||||
|
|
||||||
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
|
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
|
||||||
|
|
||||||
|
is GroupEvent -> groupEventHandler.add(event, eventNote, publicNote)
|
||||||
|
|
||||||
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
|
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
|
||||||
|
|
||||||
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
|
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
|
||||||
@@ -287,6 +296,13 @@ class GiftWrapEventHandler(
|
|||||||
val innerGift = event.unwrapOrNull(account.signer) ?: return
|
val innerGift = event.unwrapOrNull(account.signer) ?: return
|
||||||
|
|
||||||
eventNote.event = event.copyNoContent()
|
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)) {
|
if (cache.justConsume(innerGift, null, false)) {
|
||||||
cache.copyRelaysFromTo(publicNote, innerGift)
|
cache.copyRelaysFromTo(publicNote, innerGift)
|
||||||
val innerGiftNote = cache.getOrCreateNote(innerGift.id)
|
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(
|
private suspend fun processExistingGiftWrap(
|
||||||
innerGiftId: String,
|
innerGiftId: String,
|
||||||
publicNote: Note,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+257
@@ -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,
|
recipientPubKey: HexKey,
|
||||||
keyPackageEventId: HexKey,
|
keyPackageEventId: HexKey,
|
||||||
relays: List<NormalizedRelayUrl>,
|
relays: List<NormalizedRelayUrl>,
|
||||||
|
nostrGroupId: HexKey? = null,
|
||||||
): WelcomeDelivery? {
|
): WelcomeDelivery? {
|
||||||
val welcomeBytes = commitResult.welcomeBytes ?: return null
|
val welcomeBytes = commitResult.welcomeBytes ?: return null
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ class MarmotWelcomeSender(
|
|||||||
relays = relays,
|
relays = relays,
|
||||||
recipientPubKey = recipientPubKey,
|
recipientPubKey = recipientPubKey,
|
||||||
signer = signer,
|
signer = signer,
|
||||||
|
nostrGroupId = nostrGroupId,
|
||||||
)
|
)
|
||||||
|
|
||||||
return WelcomeDelivery(
|
return WelcomeDelivery(
|
||||||
@@ -109,6 +111,7 @@ class MarmotWelcomeSender(
|
|||||||
recipientPubKey: HexKey,
|
recipientPubKey: HexKey,
|
||||||
keyPackageEventId: HexKey,
|
keyPackageEventId: HexKey,
|
||||||
relays: List<NormalizedRelayUrl>,
|
relays: List<NormalizedRelayUrl>,
|
||||||
|
nostrGroupId: HexKey? = null,
|
||||||
): WelcomeDelivery {
|
): WelcomeDelivery {
|
||||||
val welcomeBase64 = Base64.encode(welcomeBytes)
|
val welcomeBase64 = Base64.encode(welcomeBytes)
|
||||||
|
|
||||||
@@ -119,6 +122,7 @@ class MarmotWelcomeSender(
|
|||||||
relays = relays,
|
relays = relays,
|
||||||
recipientPubKey = recipientPubKey,
|
recipientPubKey = recipientPubKey,
|
||||||
signer = signer,
|
signer = signer,
|
||||||
|
nostrGroupId = nostrGroupId,
|
||||||
)
|
)
|
||||||
|
|
||||||
return WelcomeDelivery(
|
return WelcomeDelivery(
|
||||||
|
|||||||
+8
@@ -67,6 +67,12 @@ class WelcomeEvent(
|
|||||||
/** Content encoding (must be "base64") */
|
/** Content encoding (must be "base64") */
|
||||||
fun encoding() = tags.welcomeEncoding()
|
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
|
override fun isContentEncoded() = true
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -77,12 +83,14 @@ class WelcomeEvent(
|
|||||||
welcomeBase64: String,
|
welcomeBase64: String,
|
||||||
keyPackageEventId: HexKey,
|
keyPackageEventId: HexKey,
|
||||||
relays: List<NormalizedRelayUrl>,
|
relays: List<NormalizedRelayUrl>,
|
||||||
|
nostrGroupId: HexKey? = null,
|
||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
initializer: TagArrayBuilder<WelcomeEvent>.() -> Unit = {},
|
initializer: TagArrayBuilder<WelcomeEvent>.() -> Unit = {},
|
||||||
) = eventTemplate(KIND, welcomeBase64, createdAt) {
|
) = eventTemplate(KIND, welcomeBase64, createdAt) {
|
||||||
keyPackageEventId(keyPackageEventId)
|
keyPackageEventId(keyPackageEventId)
|
||||||
welcomeRelays(relays)
|
welcomeRelays(relays)
|
||||||
encoding()
|
encoding()
|
||||||
|
nostrGroupId?.let { addUnique(arrayOf("h", it)) }
|
||||||
initializer()
|
initializer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -57,6 +57,7 @@ object WelcomeGiftWrap {
|
|||||||
relays: List<NormalizedRelayUrl>,
|
relays: List<NormalizedRelayUrl>,
|
||||||
recipientPubKey: HexKey,
|
recipientPubKey: HexKey,
|
||||||
signer: NostrSigner,
|
signer: NostrSigner,
|
||||||
|
nostrGroupId: HexKey? = null,
|
||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
): GiftWrapEvent {
|
): GiftWrapEvent {
|
||||||
// Step 1: Build the WelcomeEvent template and sign it
|
// Step 1: Build the WelcomeEvent template and sign it
|
||||||
@@ -65,6 +66,7 @@ object WelcomeGiftWrap {
|
|||||||
welcomeBase64 = welcomeBase64,
|
welcomeBase64 = welcomeBase64,
|
||||||
keyPackageEventId = keyPackageEventId,
|
keyPackageEventId = keyPackageEventId,
|
||||||
relays = relays,
|
relays = relays,
|
||||||
|
nostrGroupId = nostrGroupId,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
)
|
)
|
||||||
val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate)
|
val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate)
|
||||||
|
|||||||
Reference in New Issue
Block a user