Merge pull request #2147 from vitorpamplona/claude/marmot-protocol-integration-xb3TT
Add Marmot MLS group messaging support to Amethyst
This commit is contained in:
@@ -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)
|
||||
|
||||
+11
@@ -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))
|
||||
|
||||
+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.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()
|
||||
|
||||
+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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
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(
|
||||
|
||||
+8
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user