mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4282 from vitorpamplona/claude/vigilant-ptolemy-ggxtwn
Add Buzz protocol event types and NIP-MP conformance fixtures
This commit is contained in:
+1
-1
@@ -270,7 +270,7 @@ class AuthCoordinator(
|
|||||||
relayUrl: NormalizedRelayUrl,
|
relayUrl: NormalizedRelayUrl,
|
||||||
): EventTemplate<RelayAuthEvent> {
|
): EventTemplate<RelayAuthEvent> {
|
||||||
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
||||||
val authTag = account.buzzAttestation.authTag() ?: return template
|
val authTag = account.buzzAttestation.authTag(template.createdAt) ?: return template
|
||||||
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -61,7 +61,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
|||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.isBuzzEditableBy
|
||||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel
|
import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel
|
||||||
@@ -283,13 +285,25 @@ fun ChatMessageActionSheet(
|
|||||||
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
|
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
|
||||||
|
|
||||||
// Editing my own chat message. Two surfaces publish an edit today, gated by type:
|
// Editing my own chat message. Two surfaces publish an edit today, gated by type:
|
||||||
// - Buzz: kind-40002 stream message → a kind-40003 edit.
|
// - Buzz: kind-9 message in a Buzz-dialect relay group (or a legacy kind-40002 one)
|
||||||
|
// → a kind-40003 edit, by its author or the author's agent owner.
|
||||||
// - Concord: kind-9 channel message or kind-1111 thread reply (carries a
|
// - Concord: kind-9 channel message or kind-1111 thread reply (carries a
|
||||||
// ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane.
|
// ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane.
|
||||||
// Both restrict to my own messages; a note is only ever one of the two, so at
|
// Both restrict to my own messages; a note is only ever one of the two, so at
|
||||||
// most one tile shows and both route through the same edit callback.
|
// most one tile shows and both route through the same edit callback.
|
||||||
val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
||||||
val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine
|
// Buzz lets the owner of an agent edit the agent's messages too, and credits a
|
||||||
|
// relay-signed message to the member it names, so its own rule decides.
|
||||||
|
val canEditBuzz =
|
||||||
|
onWantsToEditChatMessage != null &&
|
||||||
|
(
|
||||||
|
note.event is StreamMessageV2Event ||
|
||||||
|
(
|
||||||
|
note.event is ChatEvent &&
|
||||||
|
note.inGatherers?.any { it is RelayGroupChannel && BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } == true
|
||||||
|
)
|
||||||
|
) &&
|
||||||
|
note.isBuzzEditableBy(accountViewModel.userProfile().pubkeyHex)
|
||||||
val canEditConcord =
|
val canEditConcord =
|
||||||
onWantsToEditChatMessage != null &&
|
onWantsToEditChatMessage != null &&
|
||||||
(note.event is ChatEvent || note.event is CommentEvent) &&
|
(note.event is ChatEvent || note.event is CommentEvent) &&
|
||||||
|
|||||||
+1
-1
@@ -126,7 +126,7 @@ fun ChatroomMessageCompose(
|
|||||||
// reply quotes inside a DM, where the target is simply older than the loaded window (see
|
// reply quotes inside a DM, where the target is simply older than the loaded window (see
|
||||||
// LoadingReplyNote). Null keeps the default blank for every other caller.
|
// LoadingReplyNote). Null keeps the default blank for every other caller.
|
||||||
onBlank: (@Composable () -> Unit)? = null,
|
onBlank: (@Composable () -> Unit)? = null,
|
||||||
// Edit my own chat message on surfaces that support it (Buzz kind-40002 → 40003,
|
// Edit my own chat message on surfaces that support it (Buzz kind-9 or legacy 40002 → 40003,
|
||||||
// Concord kind-9 → 1010). Null for chat surfaces without message editing, which hides
|
// Concord kind-9 → 1010). Null for chat surfaces without message editing, which hides
|
||||||
// the action.
|
// the action.
|
||||||
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
|
onWantsToEditChatMessage: ((Note) -> Unit)? = null,
|
||||||
|
|||||||
+40
-10
@@ -20,6 +20,8 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -28,10 +30,15 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel
|
||||||
|
import com.vitorpamplona.amethyst.commons.buzz.ui.rememberBuzzContextualName
|
||||||
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
|
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
|
||||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
@@ -44,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
|
|||||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier
|
import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DrawAuthorInfo(
|
fun DrawAuthorInfo(
|
||||||
@@ -54,8 +62,15 @@ fun DrawAuthorInfo(
|
|||||||
// A geohash chat resolves the message's `n` nickname here (throwaway keys have no profile);
|
// A geohash chat resolves the message's `n` nickname here (throwaway keys have no profile);
|
||||||
// null everywhere else, so authors render from their profile as usual.
|
// null everywhere else, so authors render from their profile as usual.
|
||||||
val nameOverride = LocalChatDisplayNameResolver.current?.invoke(baseNote)
|
val nameOverride = LocalChatDisplayNameResolver.current?.invoke(baseNote)
|
||||||
|
// In a Buzz channel two members may share a name; Buzz tells them apart there.
|
||||||
|
val buzzChannel =
|
||||||
|
remember(baseNote) {
|
||||||
|
baseNote.inGatherers
|
||||||
|
?.firstNotNullOfOrNull { it as? RelayGroupChannel }
|
||||||
|
?.takeIf { BuzzRelayDialect.isBuzz(it.groupId.relayUrl) }
|
||||||
|
}
|
||||||
baseNote.author?.let {
|
baseNote.author?.let {
|
||||||
WatchAndDisplayUser(it, nameOverride, accountViewModel, nav)
|
WatchAndDisplayUser(it, nameOverride, buzzChannel, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,14 +95,20 @@ fun authorNameColorFor(
|
|||||||
private fun WatchAndDisplayUser(
|
private fun WatchAndDisplayUser(
|
||||||
author: User,
|
author: User,
|
||||||
nameOverride: String?,
|
nameOverride: String?,
|
||||||
|
buzzChannel: RelayGroupChannel?,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val userState by observeUserInfo(author, accountViewModel)
|
val userState by observeUserInfo(author, accountViewModel)
|
||||||
val nickname by observeUserDisplayNickname(author, accountViewModel)
|
val nickname by observeUserDisplayNickname(author, accountViewModel)
|
||||||
val petName = nickname?.petName
|
val petName = nickname?.petName
|
||||||
// A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key.
|
val profileName = userState?.info?.bestName()
|
||||||
val displayName = nameOverride ?: petName ?: userState?.info?.bestName()
|
// Buzz's contextual name ("Alice’s Honey", "Honey · 7xk2") when the channel has a namesake.
|
||||||
|
val contextual = buzzChannel?.let { rememberBuzzContextualName(it, author, accountViewModel) }?.name
|
||||||
|
val qualified = contextual != null && profileName != null && contextual != IdentityNamePolicy.trim(profileName)
|
||||||
|
// A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key, and a
|
||||||
|
// nickname the account set wins over everything else.
|
||||||
|
val displayName = nameOverride ?: petName ?: (if (qualified) contextual else profileName)
|
||||||
|
|
||||||
val isLightTheme = MaterialTheme.colorScheme.isLight
|
val isLightTheme = MaterialTheme.colorScheme.isLight
|
||||||
val nameColor =
|
val nameColor =
|
||||||
@@ -114,13 +135,22 @@ private fun WatchAndDisplayUser(
|
|||||||
ObserveAndRenderUserCards(author, Size20dp, Modifier.align(Alignment.BottomCenter), accountViewModel)
|
ObserveAndRenderUserCards(author, Size20dp, Modifier.align(Alignment.BottomCenter), accountViewModel)
|
||||||
},
|
},
|
||||||
name = {
|
name = {
|
||||||
CreateTextWithEmoji(
|
Row(
|
||||||
text = displayName ?: author.pubkeyDisplayHex(),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
color = nameColor,
|
) {
|
||||||
maxLines = 1,
|
CreateTextWithEmoji(
|
||||||
fontWeight = FontWeight.Bold,
|
text = displayName ?: author.pubkeyDisplayHex(),
|
||||||
)
|
tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
|
||||||
|
color = nameColor,
|
||||||
|
maxLines = 1,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
// An agent says so, and whose it is (its owner comes from the NIP-OA tag on its kind 0),
|
||||||
|
// unless its contextual name already does.
|
||||||
|
BuzzAgentLabel(author, userState?.nipOaOwner, accountViewModel, showOwner = !qualified)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
|
||||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||||
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||||
@@ -101,7 +102,15 @@ fun RenderBuzzEditedNote(
|
|||||||
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
// Render with the tags the edit leaves the message with (Buzz's applyEditTagOverlay): the edit's
|
||||||
|
// attachments and custom emoji, the original's channel/thread tags.
|
||||||
|
val tags =
|
||||||
|
remember(note.event, editNote.event) {
|
||||||
|
note.event
|
||||||
|
?.tags
|
||||||
|
?.let { BuzzEditTagOverlay.apply(it, editNote.event?.tags) }
|
||||||
|
?.toImmutableListOfLists() ?: EmptyTagList
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
TranslatableRichTextViewer(
|
TranslatableRichTextViewer(
|
||||||
|
|||||||
+12
-2
@@ -41,6 +41,8 @@ import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer
|
|||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.canonicalBuzzChannelName
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.isValidBuzzChannelName
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.newBuzzChannelId
|
import com.vitorpamplona.quartz.buzz.workspace.newBuzzChannelId
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
@@ -127,7 +129,13 @@ class RelayGroupMetadataViewModel : ViewModel() {
|
|||||||
var touched by mutableStateOf(false)
|
var touched by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val canPost by derivedStateOf { !isWorking && name.value.text.isNotBlank() }
|
/**
|
||||||
|
* A Buzz relay strips leading `#`s and whitespace from a channel name and refuses one that ends
|
||||||
|
* up empty, so on Buzz a name like `"# #"` can't be submitted at all.
|
||||||
|
*/
|
||||||
|
val canPost by derivedStateOf {
|
||||||
|
!isWorking && name.value.text.isNotBlank() && (!isBuzzRelay || isValidBuzzChannelName(name.value.text))
|
||||||
|
}
|
||||||
|
|
||||||
fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank()
|
fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank()
|
||||||
|
|
||||||
@@ -239,7 +247,9 @@ class RelayGroupMetadataViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun publish() {
|
private suspend fun publish() {
|
||||||
val name = name.value.text.trim()
|
// Send Buzz the name it would store anyway (no leading `#`s), so our optimistic copy and
|
||||||
|
// the relay's 39000 agree.
|
||||||
|
val name = if (isBuzzRelay) canonicalBuzzChannelName(name.value.text) else name.value.text.trim()
|
||||||
val about =
|
val about =
|
||||||
about.value.text
|
about.value.text
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
+55
@@ -40,7 +40,9 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -53,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
@@ -69,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_channel_archive
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete
|
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete_confirm
|
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete_confirm
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_unarchive
|
import com.vitorpamplona.amethyst.commons.resources.buzz_channel_unarchive
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_huddle_live
|
||||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||||
import com.vitorpamplona.amethyst.commons.resources.join
|
import com.vitorpamplona.amethyst.commons.resources.join
|
||||||
import com.vitorpamplona.amethyst.commons.resources.leave
|
import com.vitorpamplona.amethyst.commons.resources.leave
|
||||||
@@ -97,6 +101,10 @@ import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
|
|||||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RelayGroupTopBar(
|
fun RelayGroupTopBar(
|
||||||
@@ -154,6 +162,7 @@ fun RelayGroupTopBar(
|
|||||||
var showJoinCode by remember { mutableStateOf(false) }
|
var showJoinCode by remember { mutableStateOf(false) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) }
|
val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) }
|
||||||
|
val huddleLive by observeBuzzHuddleLive(channel, enabled = isBuzzRelay && membership.isMember(), accountViewModel)
|
||||||
|
|
||||||
TopBarExtensibleWithBackButton(
|
TopBarExtensibleWithBackButton(
|
||||||
title = {
|
title = {
|
||||||
@@ -207,6 +216,20 @@ fun RelayGroupTopBar(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (huddleLive) {
|
||||||
|
Icon(
|
||||||
|
symbol = MaterialSymbols.Headphones,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringRes(Res.string.buzz_huddle_live),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -556,6 +579,38 @@ private fun RoleBadge(membership: RelayGroupMembership) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a Buzz huddle is live in [channel] right now. While [enabled] (a Buzz relay we are a
|
||||||
|
* member of - the relay answers liveness only for an authorized `#h`), re-asks the relay every
|
||||||
|
* [HUDDLE_LIVENESS_REFRESH_MS] as Buzz's desktop does; a session that stops being reported ages out
|
||||||
|
* of [BuzzHuddleLivenessState] and the indicator drops.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun observeBuzzHuddleLive(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
enabled: Boolean,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
): State<Boolean> {
|
||||||
|
val seen by BuzzHuddleLivenessState.flow.collectAsStateWithLifecycle()
|
||||||
|
var now by remember { mutableLongStateOf(TimeUtils.now()) }
|
||||||
|
LaunchedEffect(channel.groupId, enabled) {
|
||||||
|
if (!enabled) return@LaunchedEffect
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
accountViewModel.account.relayGroups.refreshBuzzHuddleLiveness(channel)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.w("RelayGroupTopBar", "Huddle liveness refresh failed", e)
|
||||||
|
}
|
||||||
|
now = TimeUtils.now()
|
||||||
|
delay(HUDDLE_LIVENESS_REFRESH_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remember(channel.groupId, enabled) { derivedStateOf { enabled && BuzzHuddleLivenessState.liveSessions(channel.groupId.id, now, seen).isNotEmpty() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HUDDLE_LIVENESS_REFRESH_MS = 10_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this Buzz channel has a canvas (kind 40100) in cache, recomposing when one lands.
|
* Whether this Buzz channel has a canvas (kind 40100) in cache, recomposing when one lands.
|
||||||
*
|
*
|
||||||
|
|||||||
+61
-42
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
|
|||||||
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
|
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
|
||||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit
|
||||||
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
|
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
@@ -76,12 +77,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
|
||||||
import com.vitorpamplona.quartz.buzz.stream.mentions
|
import com.vitorpamplona.quartz.buzz.stream.mentions
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
|
||||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
||||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||||
@@ -114,6 +114,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.notify
|
|||||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
|
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
|
||||||
|
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||||
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
|
||||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
|
||||||
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||||
@@ -177,11 +178,16 @@ open class ChannelNewMessageViewModel :
|
|||||||
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
|
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
|
||||||
val replyMode = mutableStateOf(ReplyMode.INLINE)
|
val replyMode = mutableStateOf(ReplyMode.INLINE)
|
||||||
|
|
||||||
// When set, the composer is editing an existing Buzz stream message (kind 40002):
|
// When set, the composer is editing an existing Buzz message (kind 9, or a legacy 40002):
|
||||||
// the next send publishes a kind-40003 edit targeting this note instead of a new
|
// the next send publishes a kind-40003 edit targeting this note instead of a new
|
||||||
// message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage).
|
// message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage).
|
||||||
val editingBuzzMessage = mutableStateOf<Note?>(null)
|
val editingBuzzMessage = mutableStateOf<Note?>(null)
|
||||||
|
|
||||||
|
// The custom emoji the message being edited currently renders with. Buzz replaces a message's
|
||||||
|
// emoji set with the edit's whenever the edit carries any, so an edit re-sends the ones still
|
||||||
|
// used in the text alongside any newly picked ones.
|
||||||
|
private var editingBuzzEmojis: List<EmojiUrlTag> = emptyList()
|
||||||
|
|
||||||
// Explicit @-mentions resolved by the last createTemplate, used by sendPostSync for the Buzz
|
// Explicit @-mentions resolved by the last createTemplate, used by sendPostSync for the Buzz
|
||||||
// auto-invite. Kept off the draft path on purpose (see createTemplate / sendPostSync).
|
// auto-invite. Kept off the draft path on purpose (see createTemplate / sendPostSync).
|
||||||
private var pendingBuzzInviteMentions: List<User> = emptyList()
|
private var pendingBuzzInviteMentions: List<User> = emptyList()
|
||||||
@@ -295,21 +301,30 @@ open class ChannelNewMessageViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks
|
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text (its newest edit,
|
||||||
* the next send as a kind-40003 edit of it. Editing and replying are mutually
|
* if it has one) and marks the next send as a kind-40003 edit of it. Editing and replying are
|
||||||
* exclusive, so any pending reply is cleared. The caller gates this to the user's own
|
* mutually exclusive, so any pending reply is cleared. The caller gates this to the user's
|
||||||
* kind-40002 messages on a Buzz relay.
|
* own kind-9 (or legacy 40002) messages on a Buzz relay.
|
||||||
*/
|
*/
|
||||||
fun editBuzzMessage(note: Note) {
|
fun editBuzzMessage(note: Note) {
|
||||||
replyTo.value = null
|
replyTo.value = null
|
||||||
replyMode.value = ReplyMode.INLINE
|
replyMode.value = ReplyMode.INLINE
|
||||||
editingBuzzMessage.value = note
|
editingBuzzMessage.value = note
|
||||||
message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "")
|
val latestEdit = note.latestBuzzEdit()?.event
|
||||||
|
message.setTextAndPlaceCursorAtEnd(latestEdit?.content ?: note.event?.content ?: "")
|
||||||
|
// Start from what the message shows now: an edit fully replaces its attachments on Buzz,
|
||||||
|
// so the ones still referenced in the text must go out again with the edit.
|
||||||
|
val currentTags = BuzzEditTagOverlay.apply(note.event?.tags ?: emptyArray(), latestEdit?.tags)
|
||||||
|
iMetaAttachments.reset()
|
||||||
|
iMetaAttachments.addAll(currentTags.imetas())
|
||||||
|
editingBuzzEmojis = currentTags.emojis()
|
||||||
draftTag.newVersion()
|
draftTag.newVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearBuzzEdit() {
|
fun clearBuzzEdit() {
|
||||||
editingBuzzMessage.value = null
|
editingBuzzMessage.value = null
|
||||||
|
editingBuzzEmojis = emptyList()
|
||||||
|
iMetaAttachments.reset()
|
||||||
message.setTextAndPlaceCursorAtEnd("")
|
message.setTextAndPlaceCursorAtEnd("")
|
||||||
draftTag.newVersion()
|
draftTag.newVersion()
|
||||||
}
|
}
|
||||||
@@ -592,7 +607,7 @@ open class ChannelNewMessageViewModel :
|
|||||||
val pk = user.pubkeyHex
|
val pk = user.pubkeyHex
|
||||||
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
|
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
|
||||||
try {
|
try {
|
||||||
accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList())
|
accountViewModel.account.relayGroups.addRelayGroupUser(channel, pk)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}", e)
|
Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}", e)
|
||||||
@@ -640,7 +655,7 @@ open class ChannelNewMessageViewModel :
|
|||||||
// Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's
|
// Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's
|
||||||
// registry — a minichat comment sent to a workspace channel would be refused
|
// registry — a minichat comment sent to a workspace channel would be refused
|
||||||
// by the relay AFTER the composer already cleared. Buzz replies always go
|
// by the relay AFTER the composer already cleared. Buzz replies always go
|
||||||
// through the 40002 branch with its thread markers instead.
|
// through the Buzz kind-9 branch with its thread markers instead.
|
||||||
val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl))
|
val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl))
|
||||||
val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event
|
val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event
|
||||||
if (minichatParent != null) {
|
if (minichatParent != null) {
|
||||||
@@ -804,55 +819,59 @@ open class ChannelNewMessageViewModel :
|
|||||||
channel is RelayGroupChannel &&
|
channel is RelayGroupChannel &&
|
||||||
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
||||||
editingBuzzMessage.value != null -> {
|
editingBuzzMessage.value != null -> {
|
||||||
// Buzz edit (kind 40003): replaces the text of an existing kind-40002 message.
|
// Buzz edit (kind 40003): replaces the text of an existing Buzz message.
|
||||||
// build() sets the group's `h` tag plus the `e` tag pointing at the edited
|
// build() sets the group's `h` tag plus the `e` tag pointing at the edited
|
||||||
// message; content is the replacement text. Kept minimal to mirror Buzz's own
|
// message; content is the replacement text. Kept minimal to mirror Buzz's own
|
||||||
// `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author
|
// `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author
|
||||||
// match. LocalCache overlays the newest edit last-write-wins, so the edited row
|
// match. LocalCache overlays the newest edit last-write-wins, so the edited row
|
||||||
// re-renders with this content.
|
// re-renders with this content.
|
||||||
|
// Buzz's clients render an edit with the original's tags overlaid by the edit's
|
||||||
|
// (BuzzEditTagOverlay): attachments come ONLY from the edit, and custom emoji from
|
||||||
|
// the edit whenever it has any. So the edit carries every attachment and emoji the
|
||||||
|
// new text still uses, like Buzz's own `build_message_edit`, or they would vanish.
|
||||||
val target = editingBuzzMessage.value!!
|
val target = editingBuzzMessage.value!!
|
||||||
|
val editEmojis =
|
||||||
|
(emojis + editingBuzzEmojis.filter { tagger.message.contains(":${it.code}:") })
|
||||||
|
.distinctBy { it.code }
|
||||||
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message) {
|
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message) {
|
||||||
// Carry `p` mentions for anyone cited in the edited text, so a mention added
|
// Carry `p` mentions for anyone cited in the edited text, so a mention added
|
||||||
// (or kept) by an edit still notifies the member and resolves its `nostr:` ref.
|
// (or kept) by an edit still notifies the member and resolves its `nostr:` ref.
|
||||||
mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty())
|
mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty())
|
||||||
|
imetas(usedAttachments)
|
||||||
|
emojis(editEmojis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
|
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
|
||||||
// Buzz workspace message: the native kind is 40002 (stream message v2)
|
// Buzz workspace message: a kind-9 chat in Buzz's tag shape (`build_message` in
|
||||||
// scoped with the group's `h` tag; Buzz threads replies with NIP-10
|
// buzz-sdk builders.rs), which is what Buzz's own desktop, mobile and CLI clients
|
||||||
// marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"],
|
// write. Kind 40002 is only read now, for older messages. Replies thread with NIP-10
|
||||||
// collapsing to a single "reply" when the parent IS the root — mirrors
|
// marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"], collapsing to
|
||||||
// thread_tags in buzz-sdk builders.rs) plus a `p` notify to the parent
|
// a single "reply" when the parent IS the root) plus a `p` notify to the parent
|
||||||
// author. The dialect check comes from BuzzRelayDialect (marked off
|
// author. The dialect check comes from BuzzRelayDialect (marked off verified Buzz
|
||||||
// verified Buzz events), not a channel subtype: channel instances are
|
// events), not a channel subtype: channel instances are captured by screens for
|
||||||
// captured by screens for their whole life, so the dialect must be
|
// their whole life, so the dialect must be able to flip mid-session without
|
||||||
// able to flip mid-session without swapping objects.
|
// swapping objects.
|
||||||
// Reply routing (Buzz has no kind-1111): an INLINE reply is flagged `broadcast` so it stays
|
// Reply routing (Buzz has no kind-1111): an INLINE reply is flagged `broadcast` so it stays
|
||||||
// a flat timeline sibling; a MINICHAT reply omits it so the thread markers pull it into the
|
// a flat timeline sibling; a MINICHAT reply omits it so the thread markers pull it into the
|
||||||
// message's minichat (mirrors block/buzz's broadcast-vs-thread split). A non-reply is neither.
|
// message's minichat (mirrors block/buzz's broadcast-vs-thread split). A non-reply is neither.
|
||||||
val broadcastReply = replyTo.value != null && replyMode.value == ReplyMode.INLINE
|
val parent = replyTo.value
|
||||||
StreamMessageV2Event.build(channel.groupId.id, tagger.message, broadcast = broadcastReply) {
|
// The parent's resolved root when it is itself a reply (a direct reply's collapsed form
|
||||||
replyTo.value?.let { parent ->
|
// carries the root as its "reply" marker), else the parent starts the thread. A parent
|
||||||
// The parent's root marker (when it is itself a nested reply),
|
// with only a `root` marker counts as top-level: Buzz's relay validates ancestry that
|
||||||
// else the parent's OWN reply target (a direct reply's collapsed
|
// way and rejects a mis-derived root.
|
||||||
// form carries the root as its "reply" marker — Buzz's relay
|
val threadRoot = parent?.let { it.event?.tags?.buzzThreadRootForReplyTo(it.idHex) ?: it.idHex }
|
||||||
// validates ancestry and rejects a mis-derived root), else the
|
BuzzChatMessage.build(
|
||||||
// parent starts the thread.
|
channelId = channel.groupId.id,
|
||||||
val parentTags = parent.event?.tags
|
content = tagger.message,
|
||||||
val root =
|
threadRoot = threadRoot,
|
||||||
parentTags?.buzzThreadRoot()
|
replyTo = parent?.idHex,
|
||||||
?: parentTags?.buzzThreadReply()
|
|
||||||
?: parent.idHex
|
|
||||||
buzzThread(root, parent.idHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// `p` mentions for everyone cited in the body (plus the reply target, which the
|
// `p` mentions for everyone cited in the body (plus the reply target, which the
|
||||||
// tagger seeds into pTags) so a named member is notified and the Buzz relay can
|
// tagger seeds into pTags) so a named member is notified and the Buzz relay can
|
||||||
// resolve the `nostr:` reference — mirrors the mention p-tags every other chat
|
// resolve the `nostr:` reference. Deduplicated, so the reply author isn't doubled.
|
||||||
// kind above emits. Deduplicated by mentions(), so the reply author isn't doubled.
|
mentions = tagger.pTags?.map { it.pubkeyHex }.orEmpty(),
|
||||||
mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty())
|
broadcast = parent != null && replyMode.value == ReplyMode.INLINE,
|
||||||
|
) {
|
||||||
hashtags(findHashtags(tagger.message))
|
hashtags(findHashtags(tagger.message))
|
||||||
references(findURLs(tagger.message))
|
references(findURLs(tagger.message))
|
||||||
quotes(findNostrUris(tagger.message))
|
quotes(findNostrUris(tagger.message))
|
||||||
|
|||||||
+10
@@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
@@ -152,6 +153,15 @@ fun DrawAdditionalInfo(
|
|||||||
|
|
||||||
DrawPlayName(displayName)
|
DrawPlayName(displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Buzz agent says so, and whose it is: the owner from the NIP-OA tag on its kind 0,
|
||||||
|
// which opens the owner's profile.
|
||||||
|
BuzzAgentLabel(
|
||||||
|
author = baseUser,
|
||||||
|
owner = user.nipOaOwner,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onOwnerClick = { nav.nav(Route.Profile(it)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayName != user.info.name && !user.info.name.isNullOrBlank()) {
|
if (displayName != user.info.name && !user.info.name.isNullOrBlank()) {
|
||||||
|
|||||||
@@ -23,16 +23,22 @@ package com.vitorpamplona.amethyst.model
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzzEffectiveAuthor
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.isBuzzEditableBy
|
||||||
import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit
|
import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
@@ -214,6 +220,84 @@ class BuzzWorkspaceChannelTest {
|
|||||||
assertEquals("the fix", target.latestBuzzEdit()?.event?.content)
|
assertEquals("the fix", target.latestBuzzEdit()?.event?.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEditOfAKind9MessageOverlaysIt() =
|
||||||
|
runBlocking {
|
||||||
|
val channelId = newChannelId()
|
||||||
|
// Buzz channel messages are kind 9 now; a 40003 targets them the same way.
|
||||||
|
LocalCache.checkDeletionAndConsume(streamMessage(channelId, "mark the dialect"), buzzRelay, false)
|
||||||
|
val original = signer.sign(BuzzChatMessage.build(channelId, "teh fix"))
|
||||||
|
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
|
||||||
|
val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 1))
|
||||||
|
LocalCache.checkDeletionAndConsume(edit, buzzRelay, false)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"the fix",
|
||||||
|
LocalCache
|
||||||
|
.getNoteIfExists(original.id)!!
|
||||||
|
.latestBuzzEdit()
|
||||||
|
?.event
|
||||||
|
?.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theVerifiedOwnerOfAnAgentMayEditItsMessages() =
|
||||||
|
runBlocking {
|
||||||
|
val channelId = newChannelId()
|
||||||
|
val agentKeys = KeyPair()
|
||||||
|
val ownerKeys = KeyPair()
|
||||||
|
val agent = NostrSignerInternal(agentKeys)
|
||||||
|
val owner = NostrSignerInternal(ownerKeys)
|
||||||
|
|
||||||
|
val original = agent.sign(StreamMessageV2Event.build(channelId, "agent output"))
|
||||||
|
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
|
||||||
|
val ownerEdit = owner.sign(StreamMessageEditEvent.build(channelId, original.id, "corrected by owner", createdAt = original.createdAt + 10))
|
||||||
|
LocalCache.checkDeletionAndConsume(ownerEdit, buzzRelay, false)
|
||||||
|
val target = LocalCache.getNoteIfExists(original.id)!!
|
||||||
|
|
||||||
|
// Until the agent's profile names the owner, the owner is just another pubkey.
|
||||||
|
assertNull(target.latestBuzzEdit())
|
||||||
|
assertFalse(target.isBuzzEditableBy(owner.pubKey))
|
||||||
|
|
||||||
|
// The agent's kind 0 carries the owner's NIP-OA attestation for the agent key.
|
||||||
|
val attestation = OwnerAttestation.sign(agent.pubKey, "", ownerKeys.privKey!!)
|
||||||
|
val profile = agent.sign(MetadataEvent.newUser("agent", createdAt = original.createdAt) { add(attestation.toTag()) })
|
||||||
|
LocalCache.checkDeletionAndConsume(profile, buzzRelay, false)
|
||||||
|
|
||||||
|
assertTrue(target.isBuzzEditableBy(owner.pubKey))
|
||||||
|
assertEquals("corrected by owner", target.latestBuzzEdit()?.event?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRelaySignedMessageIsEditableByTheMemberItNames() =
|
||||||
|
runBlocking {
|
||||||
|
val channelId = newChannelId()
|
||||||
|
val relayKey = NostrSignerInternal(KeyPair())
|
||||||
|
val member = NostrSignerInternal(KeyPair())
|
||||||
|
val stranger = NostrSignerInternal(KeyPair())
|
||||||
|
|
||||||
|
// The relay-signed 39000 is what tells us the relay's key.
|
||||||
|
LocalCache.checkDeletionAndConsume(relayKey.sign(GroupMetadataEvent.build(channelId, name = "ops")), buzzRelay, false)
|
||||||
|
LocalCache.checkDeletionAndConsume(streamMessage(channelId, "mark the dialect"), buzzRelay, false)
|
||||||
|
|
||||||
|
// A workflow posted on the member's behalf: relay-signed, attributed through `actor`.
|
||||||
|
val posted = relayKey.sign(BuzzChatMessage.build(channelId, "deploy done") { add(arrayOf("actor", member.pubKey)) })
|
||||||
|
LocalCache.checkDeletionAndConsume(posted, buzzRelay, false)
|
||||||
|
val target = LocalCache.getNoteIfExists(posted.id)!!
|
||||||
|
assertEquals(member.pubKey, target.buzzEffectiveAuthor())
|
||||||
|
|
||||||
|
LocalCache.checkDeletionAndConsume(stranger.sign(StreamMessageEditEvent.build(channelId, posted.id, "nope", createdAt = posted.createdAt + 1)), buzzRelay, false)
|
||||||
|
assertNull(target.latestBuzzEdit())
|
||||||
|
LocalCache.checkDeletionAndConsume(member.sign(StreamMessageEditEvent.build(channelId, posted.id, "deploy done (v2)", createdAt = posted.createdAt + 2)), buzzRelay, false)
|
||||||
|
assertEquals("deploy done (v2)", target.latestBuzzEdit()?.event?.content)
|
||||||
|
|
||||||
|
// A user-signed message can't hand its edits to someone else with an `actor` tag.
|
||||||
|
val claimed = stranger.sign(BuzzChatMessage.build(channelId, "mine") { add(arrayOf("actor", member.pubKey)) })
|
||||||
|
LocalCache.checkDeletionAndConsume(claimed, buzzRelay, false)
|
||||||
|
assertEquals(stranger.pubKey, LocalCache.getNoteIfExists(claimed.id)!!.buzzEffectiveAuthor())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun deletingAnEditUnlinksItFromTheMessage() =
|
fun deletingAnEditUnlinksItFromTheMessage() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
+3
-3
@@ -597,7 +597,7 @@ screen speaks.
|
|||||||
| `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. |
|
| `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. |
|
||||||
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--picture URL] [--banner URL] [--parent GID\|--root] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads the current 39000 and changes only what you pass: picture, banner, subgroup links, other flags and unknown tags are carried over. |
|
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--picture URL] [--banner URL] [--parent GID\|--root] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads the current 39000 and changes only what you pass: picture, banner, subgroup links, other flags and unknown tags are carried over. |
|
||||||
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
|
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
|
||||||
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
|
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator] [--buzz-role owner\|admin\|member\|guest\|bot]` | Add or promote a user (9000, moderator). On Buzz, only `--buzz-role` sets a role; without it an existing member keeps theirs. |
|
||||||
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
|
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
|
||||||
| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. |
|
| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. |
|
||||||
|
|
||||||
@@ -605,13 +605,13 @@ screen speaks.
|
|||||||
|
|
||||||
[`block/buzz`](https://github.com/block/buzz) workspaces are NIP-29 groups on a Buzz
|
[`block/buzz`](https://github.com/block/buzz) workspaces are NIP-29 groups on a Buzz
|
||||||
relay, so create/join/leave still use `amy relaygroup`. These verbs cover the Buzz-native
|
relay, so create/join/leave still use `amy relaygroup`. These verbs cover the Buzz-native
|
||||||
surface: the kind:40002 stream message, the owner-attestation primitive (NIP-OA), and the
|
surface: the Buzz channel message (kind:9), the owner-attestation primitive (NIP-OA), and the
|
||||||
agent console (turn-metric aggregation + personas), all driving the same `quartz` models
|
agent console (turn-metric aggregation + personas), all driving the same `quartz` models
|
||||||
and `commons` aggregator the app uses.
|
and `commons` aggregator the app uses.
|
||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `amy buzz post RELAY GID <text>` | Post a kind:40002 stream message (Buzz-native) into a workspace. |
|
| `amy buzz post RELAY GID <text>` | Post a channel message into a workspace: kind:9 in Buzz's tag shape, what Buzz's own clients write. |
|
||||||
| `amy buzz read RELAY GID [--limit N] [--timeout SECS]` | Read the recent human-visible timeline (kinds 9 / 40002 / 40099). |
|
| `amy buzz read RELAY GID [--limit N] [--timeout SECS]` | Read the recent human-visible timeline (kinds 9 / 40002 / 40099). |
|
||||||
| `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. |
|
| `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. |
|
||||||
| `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. |
|
| `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. |
|
||||||
|
|||||||
@@ -830,7 +830,7 @@ private fun printUsage() {
|
|||||||
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
||||||
|
|
|
|
||||||
|Buzz (block/buzz agent workspaces — NIP-29 dialect):
|
|Buzz (block/buzz agent workspaces — NIP-29 dialect):
|
||||||
| buzz post RELAY GID <text> post a kind-40002 stream message
|
| buzz post RELAY GID <text> post a channel message (kind 9)
|
||||||
| buzz read RELAY GID [--limit N] read recent workspace messages
|
| buzz read RELAY GID [--limit N] read recent workspace messages
|
||||||
| buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline)
|
| buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline)
|
||||||
| buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics
|
| buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteClaim
|
||||||
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
|
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
|
||||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
||||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
@@ -63,7 +65,7 @@ import okhttp3.coroutines.executeAsync
|
|||||||
* `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the
|
* `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the
|
||||||
* same `quartz` models + `commons` aggregator the app uses. Buzz workspaces are NIP-29
|
* same `quartz` models + `commons` aggregator the app uses. Buzz workspaces are NIP-29
|
||||||
* groups, so join/leave/create still go through `amy relaygroup`; this verb group covers
|
* groups, so join/leave/create still go through `amy relaygroup`; this verb group covers
|
||||||
* the Buzz-native pieces: stream messages (40002), the owner-attestation primitive (OA),
|
* the Buzz-native pieces: channel messages (kind 9 in Buzz's shape), the owner-attestation primitive (OA),
|
||||||
* and the agent console (turn-metric aggregation + personas).
|
* and the agent console (turn-metric aggregation + personas).
|
||||||
*/
|
*/
|
||||||
object BuzzCommands {
|
object BuzzCommands {
|
||||||
@@ -345,12 +347,24 @@ object BuzzCommands {
|
|||||||
}.toString()
|
}.toString()
|
||||||
val authEvent = ctx.signer.sign(HTTPAuthorizationEvent.build(claimUrl, "POST", claimReq.encodeToByteArray()))
|
val authEvent = ctx.signer.sign(HTTPAuthorizationEvent.build(claimUrl, "POST", claimReq.encodeToByteArray()))
|
||||||
val (claimCode, claimBody) = httpPost(http, claimUrl, claimReq, authEvent.toAuthToken())
|
val (claimCode, claimBody) = httpPost(http, claimUrl, claimReq, authEvent.toAuthToken())
|
||||||
if (claimCode != 200) return Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody")
|
if (claimCode != 200) {
|
||||||
|
return when (BuzzInviteClaim.errorOf(claimBody)) {
|
||||||
|
BuzzInviteClaim.ERROR_EXHAUSTED -> Output.error("exhausted", "this invite has no uses left; ask for a new one")
|
||||||
|
BuzzInviteClaim.ERROR_EXPIRED -> Output.error("expired", "this invite has expired")
|
||||||
|
BuzzInviteClaim.ERROR_INVALID -> Output.error("invalid", "this invite is not valid for this workspace (revoked, mistyped, or never minted here)")
|
||||||
|
BuzzInviteClaim.ERROR_JOIN_POLICY_REQUIRED ->
|
||||||
|
Output.error("policy_required", "this workspace requires accepting its terms + age attestation; re-run with --accept-policy to consent")
|
||||||
|
else -> Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val result = jsonParser.parseToJsonElement(claimBody).jsonObject
|
val result = jsonParser.parseToJsonElement(claimBody).jsonObject
|
||||||
|
val status = result["status"]?.jsonPrimitive?.content
|
||||||
Output.emit(
|
Output.emit(
|
||||||
mapOf(
|
mapOf(
|
||||||
"status" to result["status"]?.jsonPrimitive?.content,
|
"status" to status,
|
||||||
|
// Distinguish a no-op claim (already a member; no invite use consumed) from a join.
|
||||||
|
"already_member" to (status == BuzzInviteClaim.STATUS_ALREADY_MEMBER),
|
||||||
"community_id" to (result["community_id"]?.jsonPrimitive?.content ?: invite.communityId),
|
"community_id" to (result["community_id"]?.jsonPrimitive?.content ?: invite.communityId),
|
||||||
"role" to (result["role"]?.jsonPrimitive?.content ?: invite.role),
|
"role" to (result["role"]?.jsonPrimitive?.content ?: invite.role),
|
||||||
"host" to invite.host,
|
"host" to invite.host,
|
||||||
@@ -389,7 +403,7 @@ object BuzzCommands {
|
|||||||
http.newCall(builder.build()).executeAsync().use { it.code to it.body.string() }
|
http.newCall(builder.build()).executeAsync().use { it.code to it.body.string() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `buzz post RELAY GID <text>` → publishes a kind-40002 stream message with an `h` tag. */
|
/** `buzz post RELAY GID <text>` → publishes a kind-9 channel message in Buzz's shape (`build_message`). */
|
||||||
private suspend fun post(
|
private suspend fun post(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
@@ -398,7 +412,7 @@ object BuzzCommands {
|
|||||||
val text = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
val text = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||||
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
|
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
|
||||||
return publishScoped(dataDir, rest, usage) { _, groupId, _ ->
|
return publishScoped(dataDir, rest, usage) { _, groupId, _ ->
|
||||||
StreamMessageV2Event.build(groupId, text)
|
BuzzChatMessage.build(groupId, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +538,9 @@ object BuzzCommands {
|
|||||||
"total_tokens" to metrics.totals.totalTokens,
|
"total_tokens" to metrics.totals.totalTokens,
|
||||||
"input_tokens" to metrics.totals.inputTokens,
|
"input_tokens" to metrics.totals.inputTokens,
|
||||||
"output_tokens" to metrics.totals.outputTokens,
|
"output_tokens" to metrics.totals.outputTokens,
|
||||||
|
// null = never reported (NIP-AM: an omitted cache component is unknown, not zero)
|
||||||
|
"cache_read_tokens" to metrics.totals.cacheReadTokens,
|
||||||
|
"cache_write_tokens" to metrics.totals.cacheWriteTokens,
|
||||||
"turns" to metrics.totalTurns,
|
"turns" to metrics.totalTurns,
|
||||||
"sessions" to metrics.totalSessions,
|
"sessions" to metrics.totalSessions,
|
||||||
"agents" to metrics.agents.size,
|
"agents" to metrics.agents.size,
|
||||||
|
|||||||
+13
-3
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.Args
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Output
|
import com.vitorpamplona.amethyst.cli.Output
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLES
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
@@ -258,13 +259,19 @@ object RelayGroupModerationCommands {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]` → 9000. */
|
/**
|
||||||
|
* `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator] [--buzz-role ROLE]` → 9000.
|
||||||
|
*
|
||||||
|
* Buzz ignores the roles in the `p` tag and reads only a top-level `role` tag
|
||||||
|
* (owner|admin|member|guest|bot); `--buzz-role` sets it. Without it a Buzz relay makes no
|
||||||
|
* role change: an existing member keeps their role and a newcomer joins as `member`.
|
||||||
|
*/
|
||||||
suspend fun putUser(
|
suspend fun putUser(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
rest: Array<String>,
|
rest: Array<String>,
|
||||||
): Int {
|
): Int {
|
||||||
val args = Args(rest)
|
val args = Args(rest)
|
||||||
val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]"
|
val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator] [--buzz-role owner|admin|member|guest|bot]"
|
||||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||||
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||||
@@ -275,12 +282,14 @@ object RelayGroupModerationCommands {
|
|||||||
?.split(',')
|
?.split(',')
|
||||||
?.map { it.trim() }
|
?.map { it.trim() }
|
||||||
?.filter { it.isNotEmpty() } ?: emptyList()
|
?.filter { it.isNotEmpty() } ?: emptyList()
|
||||||
|
val buzzRole = args.flag("buzz-role")?.trim()?.lowercase()
|
||||||
|
if (buzzRole != null && buzzRole !in BUZZ_ROLES) return Output.error("bad_args", usage)
|
||||||
args.rejectUnknown()
|
args.rejectUnknown()
|
||||||
|
|
||||||
Context.open(dataDir).use { ctx ->
|
Context.open(dataDir).use { ctx ->
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
val pubkey = ctx.requireUserHex(user)
|
val pubkey = ctx.requireUserHex(user)
|
||||||
val signed = ctx.signer.sign(GroupPutUserEvent.build(groupId, listOf(pubkey to roles)))
|
val signed = ctx.signer.sign(GroupPutUserEvent.build(groupId, listOf(pubkey to roles), buzzRole = buzzRole))
|
||||||
val ack = ctx.publish(signed, setOf(relay))
|
val ack = ctx.publish(signed, setOf(relay))
|
||||||
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
|
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
|
||||||
Output.emit(
|
Output.emit(
|
||||||
@@ -290,6 +299,7 @@ object RelayGroupModerationCommands {
|
|||||||
"relay" to relay.url,
|
"relay" to relay.url,
|
||||||
"pubkey" to pubkey,
|
"pubkey" to pubkey,
|
||||||
"roles" to roles,
|
"roles" to roles,
|
||||||
|
"buzz_role" to buzzRole,
|
||||||
"published" to ack.values.any { it.accepted },
|
"published" to ack.values.any { it.accepted },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -186,8 +186,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
|
|||||||
import com.vitorpamplona.amethyst.commons.util.logTime
|
import com.vitorpamplona.amethyst.commons.util.logTime
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
|
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||||
@@ -2219,7 +2218,7 @@ class Account(
|
|||||||
// [com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply]).
|
// [com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply]).
|
||||||
//
|
//
|
||||||
// Attached media rides as URLs appended to the content.
|
// Attached media rides as URLs appended to the content.
|
||||||
val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id
|
val root = rootEvent.tags.buzzThreadRootForReplyTo(rootEvent.id)
|
||||||
signer.sign(
|
signer.sign(
|
||||||
ChatEvent.build(finalText) {
|
ChatEvent.build(finalText) {
|
||||||
hTag(group.groupId.id)
|
hTag(group.groupId.id)
|
||||||
|
|||||||
+65
-8
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.model
|
package com.vitorpamplona.amethyst.commons.model
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
|
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
@@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembe
|
|||||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent
|
||||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||||
@@ -41,13 +43,17 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
||||||
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
|
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_BOT
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_GUEST
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
|
||||||
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_OWNER
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
|
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
|
||||||
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.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
@@ -75,9 +81,11 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin
|
|||||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin
|
||||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-29 relay-group and Buzz-workspace orchestration for an [Account]:
|
* NIP-29 relay-group and Buzz-workspace orchestration for an [Account]:
|
||||||
@@ -158,6 +166,21 @@ class AccountRelayGroupActions(
|
|||||||
?.substringBefore('"')
|
?.substringBefore('"')
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks [channel]'s Buzz relay which huddle sessions in it are live right now and records them
|
||||||
|
* in [BuzzHuddleLivenessState]. The relay synthesizes kind-48104 answers only for a REQ whose
|
||||||
|
* every filter is exactly `kinds:[48104]` with an authorized `#h`, so this is its own REQ, never
|
||||||
|
* part of the channel's timeline subscription. Buzz's desktop repeats it every 10 seconds.
|
||||||
|
*/
|
||||||
|
suspend fun refreshBuzzHuddleLiveness(channel: RelayGroupChannel) {
|
||||||
|
val filter = HuddleLivenessEvent.filter(listOf(channel.groupId.id))
|
||||||
|
val now = TimeUtils.now()
|
||||||
|
account.client.fetchAll(channel.groupId.relayUrl, filter, idleTimeoutMs = 8_000).forEach { event ->
|
||||||
|
if (event !is HuddleLivenessEvent || event.channelId() != channel.groupId.id) return@forEach
|
||||||
|
event.sessionId()?.let { BuzzHuddleLivenessState.record(channel.groupId.id, it, now) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
|
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
|
||||||
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
|
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
|
||||||
val template = DmHideEvent.build(channel.groupId.id)
|
val template = DmHideEvent.build(channel.groupId.id)
|
||||||
@@ -231,6 +254,7 @@ class AccountRelayGroupActions(
|
|||||||
* the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker
|
* the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker
|
||||||
* offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write.
|
* offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
suspend fun publishBuzzWorkflowDef(
|
suspend fun publishBuzzWorkflowDef(
|
||||||
relay: NormalizedRelayUrl,
|
relay: NormalizedRelayUrl,
|
||||||
channelId: String,
|
channelId: String,
|
||||||
@@ -238,7 +262,8 @@ class AccountRelayGroupActions(
|
|||||||
yaml: String,
|
yaml: String,
|
||||||
): String? {
|
): String? {
|
||||||
if (!account.isWriteable()) return null
|
if (!account.isWriteable()) return null
|
||||||
val workflowId = RandomInstance.randomChars(16)
|
// The relay parses the `d` tag as a UUID and refuses anything else.
|
||||||
|
val workflowId = Uuid.random().toString()
|
||||||
val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
|
val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
|
||||||
account.cache.justConsumeMyOwnEvent(signed)
|
account.cache.justConsumeMyOwnEvent(signed)
|
||||||
account.client.publish(signed, setOf(relay))
|
account.client.publish(signed, setOf(relay))
|
||||||
@@ -493,8 +518,28 @@ class AccountRelayGroupActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add [pubkey] to the group (or change its roles) with a kind 9000 put-user
|
* Add [pubkey] to the group as a plain member with a kind 9000 put-user event, without
|
||||||
* event (moderator only). Pass an empty [roles] list for a plain member.
|
* touching the role of someone who is already in it.
|
||||||
|
*
|
||||||
|
* On Buzz this sends **no** `role` tag: the relay reads a missing role as "no role change",
|
||||||
|
* keeping an existing member's role and defaulting only a newcomer to `member`. Sending
|
||||||
|
* `role=member` instead would demote an admin who happened to be re-added, or be refused
|
||||||
|
* outright (`only owners/admins may change an active member's role`) when the actor is not
|
||||||
|
* elevated. Ground truth: `handle_put_user` in `buzz-relay/src/handlers/side_effects.rs` and
|
||||||
|
* `decide_put_user` in `channel_authz.rs`.
|
||||||
|
*/
|
||||||
|
suspend fun addRelayGroupUser(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
pubkey: HexKey,
|
||||||
|
) {
|
||||||
|
val template = GroupPutUserEvent.build(channel.groupId.id, listOf(pubkey to emptyList()))
|
||||||
|
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set [pubkey]'s roles in the group with a kind 9000 put-user event (moderator only). An
|
||||||
|
* empty [roles] list makes them a plain member - on Buzz that is an explicit demotion to
|
||||||
|
* `member`. To add someone without changing a role, use [addRelayGroupUser].
|
||||||
*/
|
*/
|
||||||
suspend fun putRelayGroupUser(
|
suspend fun putRelayGroupUser(
|
||||||
channel: RelayGroupChannel,
|
channel: RelayGroupChannel,
|
||||||
@@ -506,10 +551,7 @@ class AccountRelayGroupActions(
|
|||||||
// the whole put-user, which is why an unmapped role must become `member` rather than travel.
|
// the whole put-user, which is why an unmapped role must become `member` rather than travel.
|
||||||
val buzzRole =
|
val buzzRole =
|
||||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||||
when {
|
buzzRoleFor(roles)
|
||||||
roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN
|
|
||||||
else -> BUZZ_ROLE_MEMBER
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -606,3 +648,18 @@ class AccountRelayGroupActions(
|
|||||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps NIP-29 role names onto Buzz's closed role set (owner/admin/member/guest/bot), picking the
|
||||||
|
* most privileged one present. Anything else, including NIP-29's `moderator`, becomes `member`.
|
||||||
|
*/
|
||||||
|
internal fun buzzRoleFor(roles: List<String>): String {
|
||||||
|
fun has(role: String) = roles.any { it.equals(role, true) }
|
||||||
|
return when {
|
||||||
|
has(BUZZ_ROLE_OWNER) -> BUZZ_ROLE_OWNER
|
||||||
|
has(RelayGroupMembership.ROLE_ADMIN) || has(BUZZ_ROLE_ADMIN) -> BUZZ_ROLE_ADMIN
|
||||||
|
has(BUZZ_ROLE_BOT) -> BUZZ_ROLE_BOT
|
||||||
|
has(BUZZ_ROLE_GUEST) -> BUZZ_ROLE_GUEST
|
||||||
|
else -> BUZZ_ROLE_MEMBER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+68
-8
@@ -20,11 +20,15 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.model
|
package com.vitorpamplona.amethyst.commons.model
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
|
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
|
||||||
|
import com.vitorpamplona.quartz.utils.Hex
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -33,9 +37,11 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
* in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the
|
* in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the
|
||||||
* note rather than the cache.
|
* note rather than the cache.
|
||||||
*
|
*
|
||||||
* All four kinds apply ONLY edits authored by the edited note's own author: the send side gates
|
* Every kind applies ONLY edits the message's author was entitled to make, checked here rather
|
||||||
* editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord,
|
* than trusted to the relay (Buzz) or an encrypted-plane peer (Concord, Marmot), so a
|
||||||
* Marmot) is trusted to enforce that, so a foreign-authored edit never rewrites your message.
|
* foreign-authored edit never rewrites your message. For Concord, Marmot and kind-1010 that is the
|
||||||
|
* note's own author. Buzz also lets the owner of an agent edit the agent's messages, and credits a
|
||||||
|
* relay-signed message to the member it names ([buzzEffectiveAuthor], [buzzEditorsOf]).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,11 +58,65 @@ fun Note.textNoteModifications(): List<Note> {
|
|||||||
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The kind-40003 Buzz edit overlaying this message, or null — author-only, newest by created_at. */
|
/**
|
||||||
fun Note.latestBuzzEdit(): Note? {
|
* Who a Buzz message counts as written by. Normally its signer; but when the channel's relay signed
|
||||||
val authorHex = author?.pubkeyHex ?: return null
|
* it on a member's behalf (a workflow posting, a legacy relay-attributed message) the member named in
|
||||||
return edits
|
* its `actor` tag, else its first `p` tag. The relay's key is the author of the channel's
|
||||||
.filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex }
|
* relay-signed 39000 metadata, so a user-signed message can never claim someone else this way.
|
||||||
|
* Mirrors `effective_message_author` in Buzz's `buzz-relay/src/handlers/ingest.rs` and
|
||||||
|
* `resolveEventAuthorPubkey` in its desktop client (which also wants the `h` tag before reading `p`).
|
||||||
|
*/
|
||||||
|
fun Note.buzzEffectiveAuthor(): HexKey? {
|
||||||
|
val event = event ?: return author?.pubkeyHex
|
||||||
|
val relayKey = inGatherers?.firstNotNullOfOrNull { (it as? RelayGroupChannel)?.event?.pubKey }
|
||||||
|
if (relayKey != null && event.pubKey == relayKey) {
|
||||||
|
event.tags.firstOrNull { it.size > 1 && it[0] == "actor" && Hex.isHex64(it[1]) }?.let { return it[1] }
|
||||||
|
if (event.tags.any { it.size > 1 && it[0] == "h" }) {
|
||||||
|
event.tags.firstOrNull { it.size > 1 && it[0] == "p" && Hex.isHex64(it[1]) }?.let { return it[1] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return event.pubKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [signer] may edit this Buzz message: its [buzzEffectiveAuthor], or the owner that author
|
||||||
|
* declares through a verified NIP-OA `auth` tag on its kind-0 profile (Buzz lets the owning human
|
||||||
|
* edit its agent's messages). Mirrors `validate_edit_ownership` on the relay and
|
||||||
|
* `isAuthorizedMessageEdit` in Buzz's desktop client.
|
||||||
|
*/
|
||||||
|
fun Note.isBuzzEditableBy(
|
||||||
|
signer: HexKey,
|
||||||
|
users: (HexKey) -> User? = LocalCache::getUserIfExists,
|
||||||
|
): Boolean {
|
||||||
|
val author = buzzEffectiveAuthor() ?: return false
|
||||||
|
return signer == author || signer == buzzOwnerOf(author, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buzzOwnerOf(
|
||||||
|
author: HexKey,
|
||||||
|
users: (HexKey) -> User?,
|
||||||
|
): HexKey? =
|
||||||
|
users(author)
|
||||||
|
?.metadataOrNull()
|
||||||
|
?.flow
|
||||||
|
?.value
|
||||||
|
?.nipOaOwner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind-40003 Buzz edit overlaying this message, or null: the newest ([createdAt], then id) of
|
||||||
|
* the edits its author or the author's agent owner made ([isBuzzEditableBy]).
|
||||||
|
*/
|
||||||
|
fun Note.latestBuzzEdit(users: (HexKey) -> User? = LocalCache::getUserIfExists): Note? {
|
||||||
|
val candidates = edits.filter { it.event is StreamMessageEditEvent }
|
||||||
|
if (candidates.isEmpty()) return null
|
||||||
|
val author = buzzEffectiveAuthor() ?: return null
|
||||||
|
// Resolve the owner only when some edit is not the author's own; it costs a signature check.
|
||||||
|
val owner by lazy { buzzOwnerOf(author, users) }
|
||||||
|
return candidates
|
||||||
|
.filter {
|
||||||
|
val signer = it.author?.pubkeyHex
|
||||||
|
signer != null && (signer == author || signer == owner)
|
||||||
|
}
|
||||||
// idHex tie-break so a same-second pair resolves identically on every client.
|
// idHex tie-break so a same-second pair resolves identically on every client.
|
||||||
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
|
.maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex }))
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
+105
@@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* 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.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.Account
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.tags.ExpectedRevisionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes a Buzz canvas (kind 40100) edit with the writer discipline Buzz's own clients use
|
||||||
|
* (`set_canvas` in `desktop/src-tauri/src/commands/canvas.rs`):
|
||||||
|
*
|
||||||
|
* - the write asserts the head the editor was opened on with `["expected-revision", <head id>]`,
|
||||||
|
* or `none` when there was no canvas yet, so the relay refuses it (`conflict: …`) instead of
|
||||||
|
* silently overwriting someone else's newer revision;
|
||||||
|
* - it is stamped `created_at = max(now, head.created_at + 1)` ([CanvasEvent.writeCreatedAt]) so
|
||||||
|
* it sorts strictly ahead of that head, and is refused outright when the head sits too far in
|
||||||
|
* the future to ratchet past.
|
||||||
|
*
|
||||||
|
* Unlike the fire-and-forget broadcaster, this waits for the relay's OK: a rejected write must
|
||||||
|
* not be folded into [BuzzWorkspaceStates] as the new head, and the conflict has to reach the UI.
|
||||||
|
*/
|
||||||
|
object BuzzCanvasWriter {
|
||||||
|
sealed interface Outcome {
|
||||||
|
/** The relay stored the revision; it is now the local head too. */
|
||||||
|
data class Saved(
|
||||||
|
val event: CanvasEvent,
|
||||||
|
) : Outcome
|
||||||
|
|
||||||
|
/** The canvas changed since the editor loaded it (the relay's `conflict:` rejection). */
|
||||||
|
data class Conflict(
|
||||||
|
val message: String,
|
||||||
|
) : Outcome
|
||||||
|
|
||||||
|
/** The loaded head is timestamped too far in the future to write after; nothing was sent. */
|
||||||
|
data object HeadTooFarInFuture : Outcome
|
||||||
|
|
||||||
|
/** Any other rejection, or no answer at all. */
|
||||||
|
data class Failed(
|
||||||
|
val message: String,
|
||||||
|
) : Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs and publishes [markdown] as the new canvas of [channelId] on [relay], asserting the
|
||||||
|
* head the editor started from ([headId] / [headCreatedAt], both null when there was none).
|
||||||
|
*/
|
||||||
|
suspend fun save(
|
||||||
|
account: Account,
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
channelId: String,
|
||||||
|
markdown: String,
|
||||||
|
headId: HexKey?,
|
||||||
|
headCreatedAt: Long?,
|
||||||
|
now: Long = TimeUtils.now(),
|
||||||
|
): Outcome {
|
||||||
|
val createdAt = CanvasEvent.writeCreatedAt(headCreatedAt, now) ?: return Outcome.HeadTooFarInFuture
|
||||||
|
val template = CanvasEvent.build(channelId, markdown, createdAt, expectedRevision = headId ?: ExpectedRevisionTag.NONE)
|
||||||
|
val signed = account.signer.sign(template)
|
||||||
|
|
||||||
|
var results = account.client.publishAndCollectResults(signed, setOf(relay))
|
||||||
|
// A cold socket answers the first write with `auth-required` while our AUTH lands; the
|
||||||
|
// relay does not replay it, so send once more on the now-authenticated connection.
|
||||||
|
if (results.values.none { it.accepted } && results.values.any { it.message.contains("auth-required", ignoreCase = true) }) {
|
||||||
|
results = account.client.publishAndCollectResults(signed, setOf(relay))
|
||||||
|
}
|
||||||
|
|
||||||
|
val outcome = classify(signed, results.values)
|
||||||
|
if (outcome is Outcome.Saved) account.cache.justConsumeMyOwnEvent(signed)
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the relay's OK answers to an [Outcome]: any acceptance wins, then a `conflict:`, then the first reason. */
|
||||||
|
fun classify(
|
||||||
|
event: CanvasEvent,
|
||||||
|
results: Collection<PublishResult>,
|
||||||
|
): Outcome {
|
||||||
|
if (results.any { it.accepted }) return Outcome.Saved(event)
|
||||||
|
results.firstOrNull { CanvasEvent.isConflict(it.message) }?.let { return Outcome.Conflict(it.message) }
|
||||||
|
return Outcome.Failed(results.firstOrNull()?.message ?: PublishResult.NO_RESPONSE)
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.model.buzz
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -50,10 +51,12 @@ class BuzzHeldAttestations(
|
|||||||
val flow: StateFlow<OwnerAttestation?> = held
|
val flow: StateFlow<OwnerAttestation?> = held
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `auth` tag to attach to this account's NIP-42 AUTH event, or null when no verified
|
* The `auth` tag to attach to an AUTH event signed at [authCreatedAt], or null when no verified
|
||||||
* attestation is held.
|
* attestation is held or its `created_at<` / `created_at>` bounds exclude that moment. Buzz
|
||||||
|
* checks those bounds against the AUTH event's timestamp and refuses membership outside them,
|
||||||
|
* so an expired credential stays held (the UI can say it expired) but is not presented.
|
||||||
*/
|
*/
|
||||||
fun authTag(): Array<String>? = held.value?.toTag()
|
fun authTag(authCreatedAt: Long = TimeUtils.now()): Array<String>? = held.value?.takeIf { it.isValidAt(authCreatedAt) }?.toTag()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores [attestation] as authorizing this account, if it verifies for [agentPubKey]. Returns
|
* Stores [attestation] as authorizing this account, if it verifies for [agentPubKey]. Returns
|
||||||
|
|||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* 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.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
|
import kotlinx.collections.immutable.PersistentMap
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Buzz huddle sessions are live right now, per channel, fed by the relay-synthesized
|
||||||
|
* kind-48104 liveness events (`HuddleLivenessEvent`). The relay builds those on demand from its
|
||||||
|
* live huddle table, answers only for sessions that are live, and never stores them, so a
|
||||||
|
* session's liveness is "seen in a recent answer": Buzz's desktop re-asks every 10 seconds and
|
||||||
|
* treats a session that stops appearing as ended. [LIVE_WINDOW_SECS] leaves room for two missed
|
||||||
|
* polls before a huddle drops off.
|
||||||
|
*
|
||||||
|
* Shape: `channelId -> sessionId -> last seen (seconds)`. Lock-guarded because `LocalCache`
|
||||||
|
* consume runs on several relay reader threads. Process-wide singleton like [BuzzPresenceState].
|
||||||
|
*/
|
||||||
|
object BuzzHuddleLivenessState {
|
||||||
|
const val LIVE_WINDOW_SECS = 30L
|
||||||
|
|
||||||
|
private val lock = KmpLock()
|
||||||
|
private val mutableSeen = MutableStateFlow<PersistentMap<String, PersistentMap<String, Long>>>(persistentMapOf())
|
||||||
|
|
||||||
|
/** `channelId -> sessionId -> last seen`; collect it and filter with [liveSessions]. */
|
||||||
|
val flow: StateFlow<Map<String, Map<String, Long>>> = mutableSeen
|
||||||
|
|
||||||
|
/** Records that the relay reported [sessionId] in [channelId] live as of [seenAtSecs]. */
|
||||||
|
fun record(
|
||||||
|
channelId: String,
|
||||||
|
sessionId: String,
|
||||||
|
seenAtSecs: Long,
|
||||||
|
) = lock.withLock {
|
||||||
|
val sessions = mutableSeen.value[channelId] ?: persistentMapOf()
|
||||||
|
val prev = sessions[sessionId]
|
||||||
|
if (prev != null && seenAtSecs <= prev) return@withLock
|
||||||
|
mutableSeen.value = mutableSeen.value.putting(channelId, sessions.putting(sessionId, seenAtSecs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sessions in [channelId] reported live within [LIVE_WINDOW_SECS] of [nowSecs]. */
|
||||||
|
fun liveSessions(
|
||||||
|
channelId: String,
|
||||||
|
nowSecs: Long,
|
||||||
|
snapshot: Map<String, Map<String, Long>> = mutableSeen.value,
|
||||||
|
): Set<String> =
|
||||||
|
snapshot[channelId]
|
||||||
|
?.filterValues { nowSecs - it <= LIVE_WINDOW_SECS }
|
||||||
|
?.keys
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
/** Test-only: clears all state so unit tests don't leak into each other. */
|
||||||
|
fun clearForTesting() =
|
||||||
|
lock.withLock {
|
||||||
|
mutableSeen.value = persistentMapOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
|
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.NamingIdentity
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.ResolvedIdentityName
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buzz's contextual identity names for one relay group: each member's label, lengthened only when
|
||||||
|
* someone else in the channel shares the name ("Alice’s Honey", "Honey (agent)", "Honey · 7xk2"). See
|
||||||
|
* [IdentityNamePolicy] for the rule. Names are compared against the channel's members, so a label only
|
||||||
|
* grows when a real collision exists there; a non-member author is resolved against the members plus
|
||||||
|
* itself.
|
||||||
|
*
|
||||||
|
* Each channel's result is cached until its roster changes or any profile or agent profile arrives
|
||||||
|
* ([invalidate], which bumps [version]); the UI can collect [version] to redraw.
|
||||||
|
*/
|
||||||
|
object BuzzIdentityNames {
|
||||||
|
private val mutableVersion = MutableStateFlow(0L)
|
||||||
|
|
||||||
|
/** Bumped whenever a name, agent profile or owner may have changed. */
|
||||||
|
val version: StateFlow<Long> = mutableVersion
|
||||||
|
|
||||||
|
/** A kind-0 or kind-10100 landed; names computed so far may be stale. */
|
||||||
|
fun invalidate() {
|
||||||
|
mutableVersion.value = mutableVersion.value + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Entry(
|
||||||
|
val members: Set<HexKey>,
|
||||||
|
val version: Long,
|
||||||
|
val viewer: HexKey,
|
||||||
|
val resolved: Map<HexKey, ResolvedIdentityName>,
|
||||||
|
val outside: MutableMap<HexKey, ResolvedIdentityName?> = HashMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val lock = KmpLock()
|
||||||
|
private val cache = HashMap<GroupId, Entry>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contextual label for [pubkey] in [channel] as [viewer] sees it, or null when there is no
|
||||||
|
* name to disambiguate (an invalid key). The label equals the plain name unless it collides.
|
||||||
|
*/
|
||||||
|
fun labelFor(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
pubkey: HexKey,
|
||||||
|
viewer: HexKey,
|
||||||
|
users: (HexKey) -> User? = LocalCache::getUserIfExists,
|
||||||
|
hasAgentProfile: (HexKey) -> Boolean = ::hasCachedAgentProfile,
|
||||||
|
): ResolvedIdentityName? {
|
||||||
|
val members = channel.allMemberKeys()
|
||||||
|
val version = mutableVersion.value
|
||||||
|
val entry =
|
||||||
|
lock.withLock {
|
||||||
|
cache[channel.groupId]?.takeIf { it.members === members && it.version == version && it.viewer == viewer }
|
||||||
|
} ?: Entry(members, version, viewer, resolve(members, viewer, users, hasAgentProfile)).also { lock.withLock { cache[channel.groupId] = it } }
|
||||||
|
|
||||||
|
val key = pubkey.lowercase()
|
||||||
|
if (key in entry.resolved) return entry.resolved[key]
|
||||||
|
return lock.withLock { entry.outside[key] }
|
||||||
|
?: resolve(members + key, viewer, users, hasAgentProfile)[key].also { found -> lock.withLock { entry.outside[key] = found } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The naming fact for [pubkey]: its profile name, and whether (and whose) agent it is. */
|
||||||
|
fun factFor(
|
||||||
|
pubkey: HexKey,
|
||||||
|
users: (HexKey) -> User?,
|
||||||
|
hasAgentProfile: (HexKey) -> Boolean = ::hasCachedAgentProfile,
|
||||||
|
): NamingIdentity {
|
||||||
|
val user = users(pubkey)
|
||||||
|
val info = user?.metadataOrNull()?.flow?.value
|
||||||
|
val owner = info?.nipOaOwner
|
||||||
|
return NamingIdentity(
|
||||||
|
pubkey = pubkey,
|
||||||
|
name = info?.info?.bestName()?.takeIf { IdentityNamePolicy.trim(it).isNotEmpty() } ?: user?.pubkeyDisplayHex() ?: pubkey.take(8),
|
||||||
|
isAgent = owner != null || hasAgentProfile(pubkey),
|
||||||
|
ownerPubkey = owner,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the cache holds a kind-10100 agent profile by [pubkey]. */
|
||||||
|
fun hasCachedAgentProfile(pubkey: HexKey): Boolean = LocalCache.getAddressableNoteIfExists(AgentProfileEvent.createAddress(pubkey))?.event is AgentProfileEvent
|
||||||
|
|
||||||
|
private fun resolve(
|
||||||
|
candidates: Set<HexKey>,
|
||||||
|
viewer: HexKey,
|
||||||
|
users: (HexKey) -> User?,
|
||||||
|
hasAgentProfile: (HexKey) -> Boolean,
|
||||||
|
): Map<HexKey, ResolvedIdentityName> {
|
||||||
|
val facts = candidates.filter { isKey(it) }.map { factFor(it, users, hasAgentProfile) }
|
||||||
|
// Owners outside the channel only lend their names ("Alice’s Honey"); they don't compete.
|
||||||
|
// Listed first so a member's own fact stays the preferred one.
|
||||||
|
val ownerFacts =
|
||||||
|
facts
|
||||||
|
.mapNotNull { it.ownerPubkey }
|
||||||
|
.filter { it !in candidates }
|
||||||
|
.distinct()
|
||||||
|
.mapNotNull { owner ->
|
||||||
|
users(owner)
|
||||||
|
?.metadataOrNull()
|
||||||
|
?.flow
|
||||||
|
?.value
|
||||||
|
?.info
|
||||||
|
?.bestName()
|
||||||
|
?.takeIf { IdentityNamePolicy.trim(it).isNotEmpty() }
|
||||||
|
?.let { NamingIdentity(owner, it) }
|
||||||
|
}
|
||||||
|
return IdentityNamePolicy.resolve(ownerFacts + facts, viewer = viewer.takeIf { isKey(it) }, candidates = facts.map { it.pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isKey(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||||
|
|
||||||
|
/** Test-only. */
|
||||||
|
fun clearForTesting() =
|
||||||
|
lock.withLock {
|
||||||
|
cache.clear()
|
||||||
|
mutableVersion.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.buzz
|
|||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -46,7 +47,10 @@ import kotlin.concurrent.Volatile
|
|||||||
class BuzzWorkspaceState {
|
class BuzzWorkspaceState {
|
||||||
private val lock = KmpLock()
|
private val lock = KmpLock()
|
||||||
|
|
||||||
/** The newest canvas (kind 40100) note for this channel, or null when none seen. */
|
/**
|
||||||
|
* The live canvas (kind 40100) note for this channel, or null when none seen — picked the way
|
||||||
|
* the relay reads its head: `created_at DESC, id ASC` (see [CanvasEvent.isNewerHead]).
|
||||||
|
*/
|
||||||
@Volatile
|
@Volatile
|
||||||
var canvasNote: Note? = null
|
var canvasNote: Note? = null
|
||||||
private set
|
private set
|
||||||
@@ -58,7 +62,10 @@ class BuzzWorkspaceState {
|
|||||||
|
|
||||||
fun updateCanvas(note: Note) =
|
fun updateCanvas(note: Note) =
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
|
val current = canvasNote
|
||||||
|
// A same-second tie goes to the smallest id, not the last arrival, so every client
|
||||||
|
// (and the relay's own CAS check) agrees on which revision is the head.
|
||||||
|
if (CanvasEvent.isNewerHead(note.createdAt() ?: 0L, note.idHex, current?.createdAt(), current?.idHex)) {
|
||||||
canvasNote = note
|
canvasNote = note
|
||||||
canvasVersion.value = canvasVersion.value + 1
|
canvasVersion.value = canvasVersion.value + 1
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-2
@@ -36,6 +36,8 @@ import com.vitorpamplona.amethyst.commons.model.UserContext
|
|||||||
import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents
|
import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||||
@@ -64,7 +66,10 @@ import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
||||||
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
|
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
|
||||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactRemovalEvent
|
||||||
import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent
|
import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadWindowBoundsEvent
|
||||||
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
|
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||||
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
|
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
|
||||||
@@ -77,6 +82,7 @@ import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
|
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
|
||||||
@@ -98,6 +104,7 @@ import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
|
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
|
||||||
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
|
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
|
||||||
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
|
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.ProjectEvent
|
||||||
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
|
||||||
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
|
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
|
||||||
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
|
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
|
||||||
@@ -119,6 +126,7 @@ import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
|||||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
|
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
|
||||||
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
|
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
|
||||||
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
|
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.teamCatalog.TeamCatalogEvent
|
||||||
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
|
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
|
||||||
@@ -1098,6 +1106,7 @@ open class EventCache :
|
|||||||
val newUserMetadata = event.contactMetaData()
|
val newUserMetadata = event.contactMetaData()
|
||||||
if (newUserMetadata != null && (wasVerified || justVerify(event))) {
|
if (newUserMetadata != null && (wasVerified || justVerify(event))) {
|
||||||
user.updateUserInfo(newUserMetadata, event)
|
user.updateUserInfo(newUserMetadata, event)
|
||||||
|
BuzzIdentityNames.invalidate()
|
||||||
if (relay != null) {
|
if (relay != null) {
|
||||||
user.addRelayBeingUsed(relay, event.createdAt)
|
user.addRelayBeingUsed(relay, event.createdAt)
|
||||||
}
|
}
|
||||||
@@ -2329,7 +2338,7 @@ open class EventCache :
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes
|
* Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes
|
||||||
* what the composer sends (40002 vs kind 9) and how new channels on the relay are
|
* what the composer sends (Buzz's kind-9 shape vs NIP-29's) and how new channels on the relay are
|
||||||
* treated, so an unverifiable frame from a buggy/hostile relay must not flip it.
|
* treated, so an unverifiable frame from a buggy/hostile relay must not flip it.
|
||||||
* The note-has-event check is the same verification gate the attach path uses.
|
* The note-has-event check is the same verification gate the attach path uses.
|
||||||
*/
|
*/
|
||||||
@@ -3757,6 +3766,11 @@ open class EventCache :
|
|||||||
is AuditEntryEvent,
|
is AuditEntryEvent,
|
||||||
is ChannelSummaryEvent,
|
is ChannelSummaryEvent,
|
||||||
is PresenceSnapshotEvent,
|
is PresenceSnapshotEvent,
|
||||||
|
// NIP-AR artifact revisions and relay-signed removal markers: queryable state, never
|
||||||
|
// chat rows - the spec keeps them out of chat, reply and unread counts. The current
|
||||||
|
// head per `d` is derived with ArtifactHeadResolver (by `prev`, not by time).
|
||||||
|
is ArtifactEvent,
|
||||||
|
is ArtifactRemovalEvent,
|
||||||
-> consumeBuzzRegularEvent(event, relay, wasVerified)
|
-> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||||
|
|
||||||
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
|
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
|
||||||
@@ -3776,8 +3790,19 @@ open class EventCache :
|
|||||||
BuzzPresenceState.record(event.subjectPubKey(), event.status(), event.createdAt)
|
BuzzPresenceState.record(event.subjectPubKey(), event.status(), event.createdAt)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
// An agent profile makes its author an agent, which can change how names in a
|
||||||
|
// channel are told apart.
|
||||||
|
is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified).also { BuzzIdentityNames.invalidate() }
|
||||||
is ObserverFrameEvent -> false
|
is ObserverFrameEvent -> false
|
||||||
is HuddleReactionEvent -> false
|
is HuddleReactionEvent -> false
|
||||||
|
// Relay-synthesized "this huddle session is live" (48104): only produced on demand
|
||||||
|
// for a dedicated REQ and never stored, so record it and keep no note.
|
||||||
|
is HuddleLivenessEvent -> {
|
||||||
|
val channel = event.channelId()
|
||||||
|
val session = event.sessionId()
|
||||||
|
if (channel != null && session != null) BuzzHuddleLivenessState.record(channel, session, TimeUtils.now())
|
||||||
|
false
|
||||||
|
}
|
||||||
// Pairing (24134) is deliberately dialect-neutral: it flows during device
|
// Pairing (24134) is deliberately dialect-neutral: it flows during device
|
||||||
// pairing before any workspace relationship is established.
|
// pairing before any workspace relationship is established.
|
||||||
is PairingEvent -> false
|
is PairingEvent -> false
|
||||||
@@ -3900,13 +3925,15 @@ open class EventCache :
|
|||||||
// Buzz addressable/replaceable state.
|
// Buzz addressable/replaceable state.
|
||||||
is PersonaEvent,
|
is PersonaEvent,
|
||||||
is TeamEvent,
|
is TeamEvent,
|
||||||
|
is TeamCatalogEvent,
|
||||||
|
is ProjectEvent,
|
||||||
is ManagedAgentEvent,
|
is ManagedAgentEvent,
|
||||||
is AgentProfileEvent,
|
|
||||||
is EngramEvent,
|
is EngramEvent,
|
||||||
is WorkflowDefEvent,
|
is WorkflowDefEvent,
|
||||||
is EventReminderEvent,
|
is EventReminderEvent,
|
||||||
is PushLeaseEvent,
|
is PushLeaseEvent,
|
||||||
is WindowBoundsEvent,
|
is WindowBoundsEvent,
|
||||||
|
is ThreadWindowBoundsEvent,
|
||||||
is ArchivedIdentitiesListEvent,
|
is ArchivedIdentitiesListEvent,
|
||||||
is RelayDiscoveryEvent,
|
is RelayDiscoveryEvent,
|
||||||
is RelayMonitorEvent,
|
is RelayMonitorEvent,
|
||||||
|
|||||||
+9
-7
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.model.chats
|
package com.vitorpamplona.amethyst.commons.model.chats
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.isBroadcast
|
||||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
@@ -31,10 +32,11 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
|||||||
* its parent message, NOT as a flat sibling in the main timeline. Three dialects express the same idea:
|
* its parent message, NOT as a flat sibling in the main timeline. Three dialects express the same idea:
|
||||||
*
|
*
|
||||||
* - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent].
|
* - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent].
|
||||||
* - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag. This
|
* - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag and
|
||||||
* is what every live Buzz client writes — `_buildReplyTags` in its Flutter client emits
|
* NOT flagged `broadcast`. This is what every live Buzz client (and Amethyst) writes —
|
||||||
* `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` +
|
* `build_message` emits `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` +
|
||||||
* `["e", parent, "", "reply"]` for a nested one, and all three of their clients send chat as kind 9.
|
* `["e", parent, "", "reply"]` for a nested one, plus `["broadcast", "1"]` when the reply should
|
||||||
|
* also show in the channel.
|
||||||
* - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT
|
* - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT
|
||||||
* flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it
|
* flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it
|
||||||
* "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the
|
* "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the
|
||||||
@@ -49,8 +51,8 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
|||||||
* as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves
|
* as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves
|
||||||
* that dialect untouched.
|
* that dialect untouched.
|
||||||
*
|
*
|
||||||
* A `broadcast=1` reply is an inline timeline sibling ("also send to channel"), matching block/buzz's
|
* A `broadcast=1` reply is an inline timeline sibling ("also send to channel") on either kind, matching
|
||||||
* `isThreadReply`. Kind 9 has no broadcast tag, so a marked kind-9 is always thread-only.
|
* block/buzz's `isBroadcastReply`, which the relay reads the same way whatever the kind.
|
||||||
*
|
*
|
||||||
* The timeline filter drops these (they belong in the minichat), the minichat count counts them, and
|
* The timeline filter drops these (they belong in the minichat), the minichat count counts them, and
|
||||||
* the minichat feed shows them — so all three agree on one definition.
|
* the minichat feed shows them — so all three agree on one definition.
|
||||||
@@ -58,7 +60,7 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
|||||||
fun isMinichatReply(event: Event?): Boolean =
|
fun isMinichatReply(event: Event?): Boolean =
|
||||||
when (event) {
|
when (event) {
|
||||||
is CommentEvent -> true
|
is CommentEvent -> true
|
||||||
is ChatEvent -> event.tags.buzzThreadReply() != null
|
is ChatEvent -> !event.tags.isBroadcast() && event.tags.buzzThreadReply() != null
|
||||||
is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null
|
is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.model.nip01Core
|
|||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||||
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||||
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
|
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
|
||||||
@@ -37,7 +39,17 @@ class UserInfo(
|
|||||||
val tags: ImmutableListOfLists<String>,
|
val tags: ImmutableListOfLists<String>,
|
||||||
val identities: List<IdentityClaimTag>,
|
val identities: List<IdentityClaimTag>,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
)
|
val pubKey: HexKey? = null,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* The owner this profile declares through a valid NIP-OA `auth` tag, making the user an agent
|
||||||
|
* of that owner (who may then edit the agent's Buzz messages), or null. Verified once per
|
||||||
|
* profile version.
|
||||||
|
*/
|
||||||
|
val nipOaOwner: HexKey? by lazy {
|
||||||
|
pubKey?.let { OwnerAttestation.verifiedOwnerOf(it, MetadataEvent.KIND, createdAt, tags.lists) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class UserMetadataCache {
|
class UserMetadataCache {
|
||||||
@@ -53,6 +65,7 @@ class UserMetadataCache {
|
|||||||
tags = metaEvent.tags.toImmutableListOfLists(),
|
tags = metaEvent.tags.toImmutableListOfLists(),
|
||||||
identities = metaEvent.identityClaims(),
|
identities = metaEvent.identityClaims(),
|
||||||
createdAt = metaEvent.createdAt,
|
createdAt = metaEvent.createdAt,
|
||||||
|
pubKey = metaEvent.pubKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
|
|||||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
@@ -139,6 +140,8 @@ class RelayGroupChannel(
|
|||||||
|
|
||||||
private fun recomputeAllMembers() {
|
private fun recomputeAllMembers() {
|
||||||
allMembers = if (admins.isEmpty()) members else members + admins.mapTo(HashSet()) { it.pubKey }
|
allMembers = if (admins.isEmpty()) members else members + admins.mapTo(HashSet()) { it.pubKey }
|
||||||
|
// Who is in the channel decides which names collide there.
|
||||||
|
if (BuzzRelayDialect.isBuzz(groupId.relayUrl)) BuzzIdentityNames.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -316,6 +319,9 @@ class RelayGroupChannel(
|
|||||||
/** Number of known members (admins are members too). */
|
/** Number of known members (admins are members too). */
|
||||||
fun memberCount(): Int = allMembers.size
|
fun memberCount(): Int = allMembers.size
|
||||||
|
|
||||||
|
/** Every known member and admin. The set is replaced, never mutated, when the roster changes. */
|
||||||
|
fun allMemberKeys(): Set<HexKey> = allMembers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The subset of this group's members/admins that [follows] contains — "people you follow who
|
* The subset of this group's members/admins that [follows] contains — "people you follow who
|
||||||
* are in here". Reads the cached [allMembers] set, so it's cheap to call per recomposition.
|
* are in here". Reads the cached [allMembers] set, so it's cheap to call per recomposition.
|
||||||
|
|||||||
+11
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses
|
|||||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.UserDependencies
|
import com.vitorpamplona.amethyst.commons.model.UserDependencies
|
||||||
|
import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
@@ -42,7 +43,7 @@ class UserStatusCache : UserDependencies {
|
|||||||
|
|
||||||
fun addStatus(note: AddressableNote) {
|
fun addStatus(note: AddressableNote) {
|
||||||
// if it's already there, quick exit
|
// if it's already there, quick exit
|
||||||
if (statuses.value.contains(note) || note.event?.content.isNullOrBlank()) return
|
if (statuses.value.contains(note) || note.isEmptyStatus()) return
|
||||||
|
|
||||||
// don't add expired statuses
|
// don't add expired statuses
|
||||||
if (note.event?.isExpired() == true) return
|
if (note.event?.isExpired() == true) return
|
||||||
@@ -52,6 +53,15 @@ class UserStatusCache : UserDependencies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing to show: no event, or blank text with no plain status emoji. An emoji-only status
|
||||||
|
* (Buzz writes `["emoji", "🌴"]` with blank content) is still a status.
|
||||||
|
*/
|
||||||
|
private fun Note.isEmptyStatus(): Boolean {
|
||||||
|
val event = event ?: return true
|
||||||
|
return if (event is UserStatusEvent) event.isCleared() else event.content.isBlank()
|
||||||
|
}
|
||||||
|
|
||||||
fun removeStatus(deleteNote: AddressableNote) {
|
fun removeStatus(deleteNote: AddressableNote) {
|
||||||
// if it's not already there, quick exit
|
// if it's not already there, quick exit
|
||||||
if (!statuses.value.contains(deleteNote)) return
|
if (!statuses.value.contains(deleteNote)) return
|
||||||
|
|||||||
+5
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.forEachChunk
|
|||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||||
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
|
import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast
|
||||||
|
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
|
import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer
|
||||||
@@ -57,6 +58,10 @@ val UserMetadataForKeyKinds =
|
|||||||
// kind:17375 — that's the user's private wallet (NIP-44 encrypted
|
// kind:17375 — that's the user's private wallet (NIP-44 encrypted
|
||||||
// to them); only the kind:10019 nutzap announcement is public.
|
// to them); only the kind:10019 nutzap announcement is public.
|
||||||
NutzapInfoEvent.KIND,
|
NutzapInfoEvent.KIND,
|
||||||
|
// Buzz agent profile (kind:10100): marks the author as an agent and carries its metadata
|
||||||
|
// and channel-add policy. Co-loaded with kind:0 so an agent is recognizable the moment it
|
||||||
|
// renders. (Its owner comes from the NIP-OA `auth` tag on the kind:0 itself.)
|
||||||
|
AgentProfileEvent.KIND,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun filterUserMetadataForKey(
|
fun filterUserMetadataForKey(
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* 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.model
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class BuzzRoleForTest {
|
||||||
|
@Test
|
||||||
|
fun picksTheMostPrivilegedBuzzRole() {
|
||||||
|
assertEquals("owner", buzzRoleFor(listOf("admin", "owner")))
|
||||||
|
assertEquals("admin", buzzRoleFor(listOf("admin")))
|
||||||
|
assertEquals("admin", buzzRoleFor(listOf("Admin")))
|
||||||
|
assertEquals("bot", buzzRoleFor(listOf("bot")))
|
||||||
|
assertEquals("guest", buzzRoleFor(listOf("guest")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownOrEmptyRolesBecomeMember() {
|
||||||
|
// Buzz has no moderator; an unparseable role would fail the whole put-user.
|
||||||
|
assertEquals("member", buzzRoleFor(listOf("moderator")))
|
||||||
|
assertEquals("member", buzzRoleFor(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.buzz.amTurnMetrics.TokenCounts
|
|||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class AgentFleetAggregatorTest {
|
class AgentFleetAggregatorTest {
|
||||||
@@ -196,4 +197,67 @@ class AgentFleetAggregatorTest {
|
|||||||
assertEquals(50L, metrics.totals.totalTokens)
|
assertEquals(50L, metrics.totals.totalTokens)
|
||||||
assertFalse(metrics.hasUnreliableEstimates)
|
assertFalse(metrics.hasUnreliableEstimates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cache(
|
||||||
|
read: Long? = null,
|
||||||
|
write: Long? = null,
|
||||||
|
) = TokenCounts(inputTokens = 100, cacheReadTokens = read, cacheWriteTokens = write)
|
||||||
|
|
||||||
|
/** NIP-AM: an omitted cache component is unknown — the total must not become a fabricated 0. */
|
||||||
|
@Test
|
||||||
|
fun unreportedCacheStaysUnknown() {
|
||||||
|
val metrics =
|
||||||
|
AgentFleetAggregator.aggregate(
|
||||||
|
listOf(
|
||||||
|
turn(agentA, "s1", 1, turn = counts(input = 100)),
|
||||||
|
turn(agentA, "s1", 2, turn = counts(input = 100)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertNull(metrics.totals.cacheReadTokens)
|
||||||
|
assertNull(metrics.totals.cacheWriteTokens)
|
||||||
|
assertEquals(200L, metrics.totals.inputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun explicitZeroCacheIsKnownZero() {
|
||||||
|
val metrics = AgentFleetAggregator.aggregate(listOf(turn(agentA, "s1", 1, turn = cache(read = 0, write = 0))))
|
||||||
|
assertEquals(0L, metrics.totals.cacheReadTokens)
|
||||||
|
assertEquals(0L, metrics.totals.cacheWriteTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cacheUsesMaxCumulativeThenReportedDeltas() {
|
||||||
|
val metrics =
|
||||||
|
AgentFleetAggregator.aggregate(
|
||||||
|
listOf(
|
||||||
|
// Session s1 reports cumulatively; s2 only per turn, and only for reads.
|
||||||
|
turn(agentA, "s1", 1, cumulative = cache(read = 40, write = 5)),
|
||||||
|
turn(agentA, "s1", 2, cumulative = cache(read = 90, write = 5)),
|
||||||
|
turn(agentA, "s2", 3, turn = cache(read = 7)),
|
||||||
|
turn(agentA, "s2", 4, turn = cache(read = 3)),
|
||||||
|
// Another agent that never reports cache at all.
|
||||||
|
turn(agentB, "s3", 5, turn = counts(input = 1)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val a = metrics.agents.single { it.agentPubKey == agentA }
|
||||||
|
assertEquals(100L, a.totals.cacheReadTokens)
|
||||||
|
assertEquals(5L, a.totals.cacheWriteTokens)
|
||||||
|
|
||||||
|
val b = metrics.agents.single { it.agentPubKey == agentB }
|
||||||
|
assertNull(b.totals.cacheReadTokens)
|
||||||
|
|
||||||
|
// Unknown sessions add nothing to the fleet total, but don't erase the known ones.
|
||||||
|
assertEquals(100L, metrics.totals.cacheReadTokens)
|
||||||
|
assertEquals(5L, metrics.totals.cacheWriteTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tokenTotalsAddUnknownAsIdentity() {
|
||||||
|
val known = TokenTotals(cacheReadTokens = 3)
|
||||||
|
val unknown = TokenTotals()
|
||||||
|
assertEquals(3L, (known + unknown).cacheReadTokens)
|
||||||
|
assertEquals(3L, (unknown + known).cacheReadTokens)
|
||||||
|
assertNull((unknown + unknown).cacheReadTokens)
|
||||||
|
assertEquals(6L, (known + known).cacheReadTokens)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* 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.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
|
||||||
|
class BuzzCanvasHeadTest {
|
||||||
|
private val channel = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
|
||||||
|
|
||||||
|
private fun canvas(
|
||||||
|
id: String,
|
||||||
|
createdAt: Long,
|
||||||
|
): Note =
|
||||||
|
Note(id).apply {
|
||||||
|
event = CanvasEvent(id, "a".repeat(64), createdAt, arrayOf(arrayOf("h", channel)), "rev $id", "sig")
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() = BuzzWorkspaceStates.clearForTesting()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newerCreatedAtWins() {
|
||||||
|
val state = BuzzWorkspaceState()
|
||||||
|
val old = canvas("1".repeat(64), 100)
|
||||||
|
val new = canvas("2".repeat(64), 101)
|
||||||
|
state.updateCanvas(new)
|
||||||
|
state.updateCanvas(old)
|
||||||
|
assertSame(new, state.canvasNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The relay reads the head as `created_at DESC, id ASC`: a same-second tie goes to the smaller id. */
|
||||||
|
@Test
|
||||||
|
fun sameSecondTieGoesToTheSmallestIdNotTheLastArrival() {
|
||||||
|
val small = canvas("1".repeat(64), 100)
|
||||||
|
val big = canvas("f".repeat(64), 100)
|
||||||
|
|
||||||
|
val bigFirst = BuzzWorkspaceState()
|
||||||
|
bigFirst.updateCanvas(big)
|
||||||
|
bigFirst.updateCanvas(small)
|
||||||
|
assertSame(small, bigFirst.canvasNote)
|
||||||
|
|
||||||
|
val smallFirst = BuzzWorkspaceState()
|
||||||
|
smallFirst.updateCanvas(small)
|
||||||
|
smallFirst.updateCanvas(big)
|
||||||
|
assertSame(small, smallFirst.canvasNote)
|
||||||
|
assertEquals(1, smallFirst.canvasUpdates.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconsumingTheHeadDoesNotBump() {
|
||||||
|
val state = BuzzWorkspaceState()
|
||||||
|
val head = canvas("1".repeat(64), 100)
|
||||||
|
state.updateCanvas(head)
|
||||||
|
state.updateCanvas(head)
|
||||||
|
assertEquals(1, state.canvasUpdates.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writerClassifiesRelayAnswers() {
|
||||||
|
val event = CanvasEvent("1".repeat(64), "a".repeat(64), 100, arrayOf(arrayOf("h", channel)), "x", "sig")
|
||||||
|
|
||||||
|
assertIs<BuzzCanvasWriter.Outcome.Saved>(BuzzCanvasWriter.classify(event, listOf(PublishResult(true, ""))))
|
||||||
|
|
||||||
|
val conflict = BuzzCanvasWriter.classify(event, listOf(PublishResult(false, "conflict: canvas changed since it was loaded")))
|
||||||
|
assertIs<BuzzCanvasWriter.Outcome.Conflict>(conflict)
|
||||||
|
assertEquals("conflict: canvas changed since it was loaded", conflict.message)
|
||||||
|
|
||||||
|
val invalid = BuzzCanvasWriter.classify(event, listOf(PublishResult(false, "invalid: canvas created_at too far in the future")))
|
||||||
|
assertIs<BuzzCanvasWriter.Outcome.Failed>(invalid)
|
||||||
|
|
||||||
|
val silent = BuzzCanvasWriter.classify(event, emptyList())
|
||||||
|
assertIs<BuzzCanvasWriter.Outcome.Failed>(silent)
|
||||||
|
assertEquals(PublishResult.NO_RESPONSE, silent.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* 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.model.buzz
|
||||||
|
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class BuzzHuddleLivenessStateTest {
|
||||||
|
@AfterTest
|
||||||
|
fun reset() = BuzzHuddleLivenessState.clearForTesting()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aReportedSessionIsLiveUntilItStopsBeingReported() {
|
||||||
|
BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 1_000)
|
||||||
|
|
||||||
|
assertEquals(setOf("s1"), BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_000))
|
||||||
|
assertEquals(setOf("s1"), BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_000 + BuzzHuddleLivenessState.LIVE_WINDOW_SECS))
|
||||||
|
// Two missed 10s polls later it is gone: the relay only answers for live sessions.
|
||||||
|
assertTrue(BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_001 + BuzzHuddleLivenessState.LIVE_WINDOW_SECS).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionsAreScopedToTheirChannelAndAnOlderReportIsIgnored() {
|
||||||
|
BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 1_000)
|
||||||
|
BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 900)
|
||||||
|
|
||||||
|
assertTrue(BuzzHuddleLivenessState.liveSessions("other", nowSecs = 1_000).isEmpty())
|
||||||
|
assertEquals(1_000L, BuzzHuddleLivenessState.flow.value["chan"]?.get("s1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
* 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.model.buzz
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.UserContext
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||||
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class BuzzIdentityNamesTest {
|
||||||
|
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
|
||||||
|
private val context = UserContext { AddressableNote(it) }
|
||||||
|
private val users = HashMap<HexKey, User>()
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun reset() = BuzzIdentityNames.clearForTesting()
|
||||||
|
|
||||||
|
private fun user(
|
||||||
|
pubkey: HexKey,
|
||||||
|
name: String,
|
||||||
|
vararg tags: Array<String>,
|
||||||
|
) = User(pubkey, context).also {
|
||||||
|
it.metadata().flow.value = UserInfo(UserMetadata().apply { this.name = name }, arrayOf(*tags).toImmutableListOfLists(), emptyList(), 1, pubkey)
|
||||||
|
users[pubkey] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channelWith(vararg members: HexKey) =
|
||||||
|
RelayGroupChannel(GroupId("6a39da2f-33c0-44f6-a050-c4da0138644a", relay)).also { channel ->
|
||||||
|
channel.updateMembers(GroupMembersEvent("00", "ff".repeat(32), 10, members.map { arrayOf("p", it) }.toTypedArray(), "", "00"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun label(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
pubkey: HexKey,
|
||||||
|
viewer: HexKey,
|
||||||
|
) = BuzzIdentityNames.labelFor(channel, pubkey, viewer, users = { users[it] }, hasAgentProfile = { false })?.name
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAgentSharingAMembersNameIsNamedAfterItsOwner() {
|
||||||
|
val alice = KeyPair()
|
||||||
|
val aliceKey = alice.pubKey.toHexKey()
|
||||||
|
val me = "1".repeat(64)
|
||||||
|
val human = "c".repeat(64)
|
||||||
|
val agent = "a".repeat(64)
|
||||||
|
user(me, "Me")
|
||||||
|
user(aliceKey, "Alice")
|
||||||
|
user(human, "Honey")
|
||||||
|
user(agent, "Honey", OwnerAttestation.sign(agent, "", alice.privKey!!).toTag())
|
||||||
|
|
||||||
|
val channel = channelWith(me, human, agent)
|
||||||
|
// The person keeps the plain name; the agent borrows its owner's.
|
||||||
|
assertEquals("Honey", label(channel, human, me))
|
||||||
|
assertEquals("Alice’s Honey", label(channel, agent, me))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun namesOnlyGrowOnARealCollision() {
|
||||||
|
val me = "1".repeat(64)
|
||||||
|
val a = "a".repeat(64)
|
||||||
|
val b = "b".repeat(64)
|
||||||
|
user(me, "Me")
|
||||||
|
user(a, "Sam")
|
||||||
|
user(b, "Sam")
|
||||||
|
|
||||||
|
// Alone in the channel, Sam is just Sam.
|
||||||
|
assertEquals("Sam", label(channelWith(me, a), a, me))
|
||||||
|
// With a namesake, both get a key suffix.
|
||||||
|
val both = channelWith(me, a, b)
|
||||||
|
assertTrue(label(both, a, me)!!.startsWith("Sam · "))
|
||||||
|
assertTrue(label(both, a, me) != label(both, b, me))
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -96,4 +96,10 @@ class MinichatReplyTest {
|
|||||||
fun `root marker alone is not a reply`() {
|
fun `root marker alone is not a reply`() {
|
||||||
assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root"))))
|
assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Also send to channel": Buzz flags the reply `broadcast`, on kind 9 exactly as on 40002. */
|
||||||
|
@Test
|
||||||
|
fun `buzz broadcast reply stays in the channel timeline`() {
|
||||||
|
assertFalse(isMinichatReply(chat(arrayOf("e", parentId, "", "reply"), arrayOf("broadcast", "1"))))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* 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.model.nip01Core
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||||
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class UserInfoAgentOwnerTest {
|
||||||
|
private val owner = KeyPair()
|
||||||
|
private val agent = KeyPair().pubKey.toHexKey()
|
||||||
|
|
||||||
|
private fun info(
|
||||||
|
vararg tags: Array<String>,
|
||||||
|
pubKey: String? = agent,
|
||||||
|
) = UserInfo(UserMetadata(), arrayOf(*tags).toImmutableListOfLists(), emptyList(), createdAt = 1_000, pubKey = pubKey)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aProfileWithAValidAttestationNamesItsOwner() {
|
||||||
|
val tag = OwnerAttestation.sign(agent, "", owner.privKey!!).toTag()
|
||||||
|
assertEquals(owner.pubKey.toHexKey(), info(tag).nipOaOwner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noAttestationOrOneForAnotherKeyMeansNoOwner() {
|
||||||
|
assertNull(info().nipOaOwner)
|
||||||
|
val forSomeoneElse = OwnerAttestation.sign(KeyPair().pubKey.toHexKey(), "", owner.privKey!!).toTag()
|
||||||
|
assertNull(info(forSomeoneElse).nipOaOwner)
|
||||||
|
// Without the profile's own pubkey there is nothing to verify against.
|
||||||
|
assertNull(info(OwnerAttestation.sign(agent, "", owner.privKey!!).toTag(), pubKey = null).nipOaOwner)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* 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.relayClient.user.watchers
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class UserMetadataForKeyKindsTest {
|
||||||
|
@Test
|
||||||
|
fun agentProfilesLoadWithEveryUserProfile() {
|
||||||
|
assertTrue(MetadataEvent.KIND in UserMetadataForKeyKinds)
|
||||||
|
assertTrue(AgentProfileEvent.KIND in UserMetadataForKeyKinds)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-6
@@ -45,26 +45,47 @@ import okhttp3.coroutines.executeAsync
|
|||||||
* `crates/buzz-relay/src/api/invites.rs`.
|
* `crates/buzz-relay/src/api/invites.rs`.
|
||||||
*/
|
*/
|
||||||
object BuzzInviteMinter {
|
object BuzzInviteMinter {
|
||||||
/** A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs). */
|
/** Shortest lifetime the relay accepts (`MIN_INVITE_TTL_SECS` in `buzz-core/src/invite.rs`). */
|
||||||
|
const val MIN_TTL_SECS = 60L
|
||||||
|
|
||||||
|
/** Longest lifetime the relay accepts: 30 days (`MAX_INVITE_TTL_SECS`). */
|
||||||
|
const val MAX_TTL_SECS = 30L * 24 * 60 * 60
|
||||||
|
|
||||||
|
/** Largest `max_uses` the relay accepts (`MAX_INVITE_USES`, the database constraint). */
|
||||||
|
const val MAX_USES = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs).
|
||||||
|
* [maxUses] / [usesRemaining] are null for an unlimited invite (the default).
|
||||||
|
*/
|
||||||
data class MintedInvite(
|
data class MintedInvite(
|
||||||
val code: String,
|
val code: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
val expiresAt: Long,
|
val expiresAt: Long,
|
||||||
|
val maxUses: Int? = null,
|
||||||
|
val usesRemaining: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (relay clamps to [60, 30d];
|
* POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (default 72 h) and an
|
||||||
* default 72 h). [httpAuth] signs the NIP-98 event over the exact URL + body; [okHttpClient]
|
* optional [maxUses] cap (default unlimited). The relay does **not** clamp: a [ttlSecs] outside
|
||||||
* supplies the transport (use a trusted-relay-posture client so a Cloudflare-fronted relay is
|
* [[MIN_TTL_SECS], [MAX_TTL_SECS]] or a [maxUses] outside [1, [MAX_USES]] is refused with a 400,
|
||||||
* reached over clearnet). Throws [IllegalStateException] with the relay's error slug on failure.
|
* so both are checked here first. [httpAuth] signs the NIP-98 event over the exact URL + body;
|
||||||
|
* [okHttpClient] supplies the transport (use a trusted-relay-posture client so a
|
||||||
|
* Cloudflare-fronted relay is reached over clearnet). Throws [IllegalStateException] with the
|
||||||
|
* relay's error message on failure.
|
||||||
*/
|
*/
|
||||||
suspend fun mint(
|
suspend fun mint(
|
||||||
relay: NormalizedRelayUrl,
|
relay: NormalizedRelayUrl,
|
||||||
ttlSecs: Long?,
|
ttlSecs: Long?,
|
||||||
okHttpClient: (String) -> OkHttpClient,
|
okHttpClient: (String) -> OkHttpClient,
|
||||||
httpAuth: suspend (url: String, method: String, body: ByteArray?) -> HTTPAuthorizationEvent,
|
httpAuth: suspend (url: String, method: String, body: ByteArray?) -> HTTPAuthorizationEvent,
|
||||||
|
maxUses: Int? = null,
|
||||||
): MintedInvite =
|
): MintedInvite =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
require(ttlSecs == null || ttlSecs in MIN_TTL_SECS..MAX_TTL_SECS) { "ttl_secs must be between $MIN_TTL_SECS and $MAX_TTL_SECS" }
|
||||||
|
require(maxUses == null || maxUses in 1..MAX_USES) { "max_uses must be between 1 and $MAX_USES" }
|
||||||
|
|
||||||
// wss://host[/..] -> https://host ; ws://host -> http://host. The endpoint is host-root.
|
// wss://host[/..] -> https://host ; ws://host -> http://host. The endpoint is host-root.
|
||||||
val wsUrl = relay.url
|
val wsUrl = relay.url
|
||||||
val scheme = if (wsUrl.startsWith("wss", ignoreCase = true)) "https" else "http"
|
val scheme = if (wsUrl.startsWith("wss", ignoreCase = true)) "https" else "http"
|
||||||
@@ -75,7 +96,7 @@ object BuzzInviteMinter {
|
|||||||
val url = httpUrl.toString()
|
val url = httpUrl.toString()
|
||||||
|
|
||||||
// Exact bytes the NIP-98 payload hash is computed over — must equal what we send.
|
// Exact bytes the NIP-98 payload hash is computed over — must equal what we send.
|
||||||
val bodyStr = ttlSecs?.let { "{\"ttl_secs\":$it}" } ?: "{}"
|
val bodyStr = requestBody(ttlSecs, maxUses)
|
||||||
val bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
|
val bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
val auth = httpAuth(url, "POST", bodyBytes)
|
val auth = httpAuth(url, "POST", bodyBytes)
|
||||||
@@ -101,9 +122,21 @@ object BuzzInviteMinter {
|
|||||||
code = tree?.get("code")?.stringOrNull().orEmpty(),
|
code = tree?.get("code")?.stringOrNull().orEmpty(),
|
||||||
url = tree?.get("url")?.stringOrNull().orEmpty(),
|
url = tree?.get("url")?.stringOrNull().orEmpty(),
|
||||||
expiresAt = tree?.get("expires_at")?.longOrNull() ?: 0L,
|
expiresAt = tree?.get("expires_at")?.longOrNull() ?: 0L,
|
||||||
|
maxUses = tree?.get("max_uses")?.longOrNull()?.toInt(),
|
||||||
|
usesRemaining = tree?.get("uses_remaining")?.longOrNull()?.toInt(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The exact JSON body of a mint request; omitted fields take the relay's defaults (72 h, unlimited). */
|
||||||
|
fun requestBody(
|
||||||
|
ttlSecs: Long?,
|
||||||
|
maxUses: Int?,
|
||||||
|
): String =
|
||||||
|
buildList {
|
||||||
|
ttlSecs?.let { add("\"ttl_secs\":$it") }
|
||||||
|
maxUses?.let { add("\"max_uses\":$it") }
|
||||||
|
}.joinToString(",", prefix = "{", postfix = "}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonElement.stringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
private fun JsonElement.stringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* 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.actions
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class BuzzInviteMinterTest {
|
||||||
|
@Test
|
||||||
|
fun requestBodyOmitsUnsetFields() {
|
||||||
|
assertEquals("{}", BuzzInviteMinter.requestBody(null, null))
|
||||||
|
assertEquals("""{"ttl_secs":3600}""", BuzzInviteMinter.requestBody(3600, null))
|
||||||
|
assertEquals("""{"max_uses":5}""", BuzzInviteMinter.requestBody(null, 5))
|
||||||
|
assertEquals("""{"ttl_secs":60,"max_uses":10000}""", BuzzInviteMinter.requestBody(60, 10_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2354,6 +2354,11 @@
|
|||||||
<string name="buzz_canvas_edit">Edit canvas</string>
|
<string name="buzz_canvas_edit">Edit canvas</string>
|
||||||
<string name="buzz_canvas_save">Save canvas</string>
|
<string name="buzz_canvas_save">Save canvas</string>
|
||||||
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
|
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
|
||||||
|
<string name="buzz_canvas_conflict">Someone else changed this canvas since you started editing. Copy your changes, reopen the canvas and apply them again.</string>
|
||||||
|
<string name="buzz_canvas_head_in_future">The current canvas is timestamped too far in the future to be edited safely.</string>
|
||||||
|
<string name="buzz_canvas_save_failed">Couldn't save the canvas: %1$s</string>
|
||||||
|
<string name="buzz_system_admin_kick">%1$s was removed by an administrator</string>
|
||||||
|
<string name="buzz_system_channel_auto_archived">This channel was archived automatically</string>
|
||||||
<string name="buzz_editing_banner">Editing message</string>
|
<string name="buzz_editing_banner">Editing message</string>
|
||||||
<string name="message_edited">(edited)</string>
|
<string name="message_edited">(edited)</string>
|
||||||
<string name="buzz_typing_one">%1$s is typing…</string>
|
<string name="buzz_typing_one">%1$s is typing…</string>
|
||||||
@@ -3149,6 +3154,12 @@
|
|||||||
<string name="buzz_attest_hold_title">Hold an attestation</string>
|
<string name="buzz_attest_hold_title">Hold an attestation</string>
|
||||||
<string name="buzz_attest_holding">Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.</string>
|
<string name="buzz_attest_holding">Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.</string>
|
||||||
<string name="buzz_attest_grants_prefix">Grants: %1$s</string>
|
<string name="buzz_attest_grants_prefix">Grants: %1$s</string>
|
||||||
|
<string name="buzz_huddle_live">Huddle live</string>
|
||||||
|
<string name="buzz_agent_label">Agent</string>
|
||||||
|
<string name="buzz_agent_managed_by">managed by %1$s</string>
|
||||||
|
<string name="buzz_agent_managed_by_you">managed by you</string>
|
||||||
|
<string name="buzz_attest_expired">This attestation has expired. Buzz relays no longer accept it; ask the owner for a new one.</string>
|
||||||
|
<string name="buzz_attest_not_yet_valid">This attestation is not valid yet. Buzz relays will accept it once its start time passes.</string>
|
||||||
<string name="buzz_attest_grants_unrestricted">any kind, any time (unrestricted)</string>
|
<string name="buzz_attest_grants_unrestricted">any kind, any time (unrestricted)</string>
|
||||||
<string name="buzz_attest_remove">Remove</string>
|
<string name="buzz_attest_remove">Remove</string>
|
||||||
<string name="buzz_attest_hold_desc">Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.</string>
|
<string name="buzz_attest_hold_desc">Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.</string>
|
||||||
@@ -3178,6 +3189,12 @@
|
|||||||
<string name="buzz_persona_provider">Provider (optional)</string>
|
<string name="buzz_persona_provider">Provider (optional)</string>
|
||||||
<string name="buzz_persona_runtime">Runtime (optional)</string>
|
<string name="buzz_persona_runtime">Runtime (optional)</string>
|
||||||
<string name="buzz_persona_avatar">Avatar URL (optional)</string>
|
<string name="buzz_persona_avatar">Avatar URL (optional)</string>
|
||||||
|
<string name="buzz_persona_description">Description (optional)</string>
|
||||||
|
<string name="buzz_persona_description_help">Public, up to 280 characters.</string>
|
||||||
|
<string name="buzz_persona_shared">Share with the workspace</string>
|
||||||
|
<string name="buzz_persona_shared_help">Other members can see and use this persona. Off keeps it to your own devices.</string>
|
||||||
|
<string name="buzz_persona_thread_sessions">One conversation per thread</string>
|
||||||
|
<string name="buzz_persona_thread_sessions_help">The agent keeps a separate conversation for each thread instead of one per channel.</string>
|
||||||
<string name="buzz_persona_publishing">Publishing…</string>
|
<string name="buzz_persona_publishing">Publishing…</string>
|
||||||
<string name="buzz_persona_publish">Publish persona</string>
|
<string name="buzz_persona_publish">Publish persona</string>
|
||||||
<string name="profile_card_follows_you">Follows you</string>
|
<string name="profile_card_follows_you">Follows you</string>
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* 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.buzz.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_agent_label
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_agent_managed_by
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_agent_managed_by_you
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
|
||||||
|
import com.vitorpamplona.quartz.buzz.identityNames.ResolvedIdentityName
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [author] has published a Buzz agent profile (kind 10100). The profile is requested with
|
||||||
|
* every user's kind 0, so this only watches the cache; it never asks a relay itself.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberHasBuzzAgentProfile(author: User): Boolean {
|
||||||
|
val note = remember(author) { LocalCache.getOrCreateAddressableNote(AgentProfileEvent.createAddress(author.pubkeyHex)) }
|
||||||
|
val hasProfile by produceState(note.event is AgentProfileEvent, note) {
|
||||||
|
note
|
||||||
|
.flow()
|
||||||
|
.metadata.stateFlow
|
||||||
|
.collect { value = note.event is AgentProfileEvent }
|
||||||
|
}
|
||||||
|
return hasProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Agent · managed by Alice" beside an agent's name, the way Buzz labels agents. [author] counts as
|
||||||
|
* an agent when it published a kind-10100 agent profile or its kind 0 names an [owner] through a
|
||||||
|
* verified NIP-OA `auth` tag; the owner part appears only for the latter. Draws nothing for a person.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BuzzAgentLabel(
|
||||||
|
author: User,
|
||||||
|
owner: HexKey?,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showOwner: Boolean = true,
|
||||||
|
onOwnerClick: ((HexKey) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val hasAgentProfile = rememberHasBuzzAgentProfile(author)
|
||||||
|
if (owner == null && !hasAgentProfile) return
|
||||||
|
|
||||||
|
val agent = stringRes(Res.string.buzz_agent_label)
|
||||||
|
val text =
|
||||||
|
when {
|
||||||
|
owner == null || !showOwner -> agent
|
||||||
|
owner == accountViewModel.userProfile().pubkeyHex -> "$agent · ${stringRes(Res.string.buzz_agent_managed_by_you)}"
|
||||||
|
else -> "$agent · ${stringRes(Res.string.buzz_agent_managed_by, observeUserNameByHex(owner, accountViewModel))}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = if (owner != null && showOwner && onOwnerClick != null) modifier.clickable { onOwnerClick(owner) } else modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [author]'s contextual name in the Buzz [channel] ([BuzzIdentityNames]): the plain name unless
|
||||||
|
* another member shares it, then "Alice’s Honey", "Honey (agent)" or "Honey · 7xk2". Recomputed when
|
||||||
|
* profiles, agent profiles or the roster change, at most every quarter second.
|
||||||
|
*/
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberBuzzContextualName(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
author: User,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
): ResolvedIdentityName? {
|
||||||
|
val versions = remember { BuzzIdentityNames.version.debounce(250) }
|
||||||
|
val version by versions.collectAsStateWithLifecycle(BuzzIdentityNames.version.value)
|
||||||
|
val viewer = accountViewModel.userProfile().pubkeyHex
|
||||||
|
return remember(channel, author, viewer, version) { BuzzIdentityNames.labelFor(channel, author.pubkeyHex, viewer) }
|
||||||
|
}
|
||||||
+15
-1
@@ -242,8 +242,22 @@ fun DisplayStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buzz sets a plain emoji with a 2-element `["emoji", "🎉"]` tag instead of inlining it in the
|
||||||
|
// text; lead with it the way Buzz's own client does. NIP-30 custom emoji are handled above.
|
||||||
|
val text =
|
||||||
|
remember(event) {
|
||||||
|
val emoji = event.statusEmoji()
|
||||||
|
if (emoji == null) {
|
||||||
|
event.content
|
||||||
|
} else if (event.content.isBlank()) {
|
||||||
|
emoji
|
||||||
|
} else {
|
||||||
|
"$emoji ${event.content}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisplayStatusInner(
|
DisplayStatusInner(
|
||||||
event.content,
|
text,
|
||||||
event.dTag(),
|
event.dTag(),
|
||||||
event.firstTaggedUrl()?.ifBlank { null },
|
event.firstTaggedUrl()?.ifBlank { null },
|
||||||
event.firstTaggedAddress(),
|
event.firstTaggedAddress(),
|
||||||
|
|||||||
+15
-1
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_attest_before_label
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_change_agent
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_change_agent
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_conditions_hint
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_conditions_hint
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_copy_tag
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_copy_tag
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_expired
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_form_desc
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_form_desc
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_generate
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_generate
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_grants_prefix
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_grants_prefix
|
||||||
@@ -85,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_desc
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_title
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_holding
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_holding
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_kind_label
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_kind_label
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_not_yet_valid
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_desc
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_desc
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_title
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_remove
|
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_remove
|
||||||
@@ -104,6 +106,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -122,7 +125,7 @@ private val KIND_OPTIONS =
|
|||||||
DropdownOption("9", "9 · Group chat message"),
|
DropdownOption("9", "9 · Group chat message"),
|
||||||
DropdownOption("1111", "1111 · Comment"),
|
DropdownOption("1111", "1111 · Comment"),
|
||||||
DropdownOption("30023", "30023 · Long-form article"),
|
DropdownOption("30023", "30023 · Long-form article"),
|
||||||
DropdownOption("40002", "40002 · Buzz minichat message"),
|
DropdownOption("40002", "40002 · Buzz stream message (legacy)"),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label }
|
private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label }
|
||||||
@@ -208,6 +211,17 @@ private fun HoldAttestationSection(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
// Buzz checks the time clauses against each AUTH, so a held credential outside them
|
||||||
|
// is silently worthless; say so rather than keep showing it as working.
|
||||||
|
val now = TimeUtils.now()
|
||||||
|
if (!mine.isValidAt(now)) {
|
||||||
|
val expired = mine.validUntil()?.let { it < now } == true
|
||||||
|
Text(
|
||||||
|
text = stringRes(if (expired) Res.string.buzz_attest_expired else Res.string.buzz_attest_not_yet_valid),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
OutlinedButton(onClick = { attestation.clear() }) {
|
OutlinedButton(onClick = { attestation.clear() }) {
|
||||||
Text(stringRes(Res.string.buzz_attest_remove))
|
Text(stringRes(Res.string.buzz_attest_remove))
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -231,8 +231,10 @@ private fun TokenBreakdown(totals: TokenTotals) {
|
|||||||
MetaRow("Input tokens", formatCount(totals.inputTokens))
|
MetaRow("Input tokens", formatCount(totals.inputTokens))
|
||||||
MetaRow("Output tokens", formatCount(totals.outputTokens))
|
MetaRow("Output tokens", formatCount(totals.outputTokens))
|
||||||
MetaRow("Total tokens", formatCount(totals.totalTokens))
|
MetaRow("Total tokens", formatCount(totals.totalTokens))
|
||||||
if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens))
|
// Null = no turn reported the component. NIP-AM forbids reading that as zero, so show a dash
|
||||||
if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens))
|
// rather than hide the row (which would look the same as a reported 0).
|
||||||
|
MetaRow("Cache read", totals.cacheReadTokens?.let(::formatCount) ?: "—")
|
||||||
|
MetaRow("Cache write", totals.cacheWriteTokens?.let(::formatCount) ?: "—")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+29
@@ -41,6 +41,8 @@ import com.vitorpamplona.amethyst.commons.buzz.ui.DropdownOption
|
|||||||
import com.vitorpamplona.amethyst.commons.buzz.ui.EditableSuggestDropdown
|
import com.vitorpamplona.amethyst.commons.buzz.ui.EditableSuggestDropdown
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_avatar
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_avatar
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_description
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_description_help
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_display_name
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_display_name
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_edit_title
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_edit_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_model
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_model
|
||||||
@@ -49,12 +51,17 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_persona_provider
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publish
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publish
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publishing
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publishing
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_runtime
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_runtime
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_shared
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_shared_help
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug_help
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug_help
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_system_prompt
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_system_prompt
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_thread_sessions
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_persona_thread_sessions_help
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
|
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
|
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.SettingSwitchItem
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
|
|
||||||
@@ -161,6 +168,28 @@ fun AgentPersonaEditScreen(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.description,
|
||||||
|
onValueChange = viewModel::onDescriptionChange,
|
||||||
|
label = { Text(stringRes(Res.string.buzz_persona_description)) },
|
||||||
|
supportingText = { Text(stringRes(Res.string.buzz_persona_description_help)) },
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
SettingSwitchItem(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
checked = state.shared,
|
||||||
|
onCheckedChange = viewModel::onSharedChange,
|
||||||
|
title = Res.string.buzz_persona_shared,
|
||||||
|
description = Res.string.buzz_persona_shared_help,
|
||||||
|
)
|
||||||
|
SettingSwitchItem(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
checked = state.threadSessions,
|
||||||
|
onCheckedChange = viewModel::onThreadSessionsChange,
|
||||||
|
title = Res.string.buzz_persona_thread_sessions,
|
||||||
|
description = Res.string.buzz_persona_thread_sessions_help,
|
||||||
|
)
|
||||||
|
|
||||||
state.error?.let {
|
state.error?.let {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
+29
-6
@@ -44,18 +44,20 @@ import kotlin.coroutines.cancellation.CancellationException
|
|||||||
* owner and addressed by `(owner, 30175, slug)`.
|
* owner and addressed by `(owner, 30175, slug)`.
|
||||||
*
|
*
|
||||||
* On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does
|
* On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does
|
||||||
* not expose (avatar, name pool, respond-to allowlist, parallelism) are preserved rather
|
* not expose (ACP command, name pool, respond-to allowlist, parallelism) are preserved rather
|
||||||
* than dropped on the next publish. The slug (the `d` tag) is immutable once chosen — a
|
* than dropped on the next publish, and the new head is stamped after the one it replaces. The
|
||||||
* different slug is a different persona.
|
* slug (the `d` tag) is immutable once chosen — a different slug is a different persona.
|
||||||
*
|
*
|
||||||
* Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when
|
* Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when
|
||||||
* none are known), so the workspace and other members see it.
|
* none are known). The relay serves it to other members only when [FormState.shared] is on
|
||||||
|
* (the `["shared","true"]` tag); otherwise only the owner's own devices can read it.
|
||||||
*/
|
*/
|
||||||
class AgentPersonaEditViewModel : ViewModel() {
|
class AgentPersonaEditViewModel : ViewModel() {
|
||||||
@Volatile private var account: Account? = null
|
@Volatile private var account: Account? = null
|
||||||
|
|
||||||
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
|
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
|
||||||
private var existing: PersonaContent? = null
|
private var existing: PersonaContent? = null
|
||||||
|
private var existingCreatedAt: Long? = null
|
||||||
private var editingSlug: String? = null
|
private var editingSlug: String? = null
|
||||||
|
|
||||||
private val _state = MutableStateFlow(FormState())
|
private val _state = MutableStateFlow(FormState())
|
||||||
@@ -74,6 +76,7 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
val event = note?.event as? PersonaEvent ?: return
|
val event = note?.event as? PersonaEvent ?: return
|
||||||
val content = event.personaOrNull() ?: return
|
val content = event.personaOrNull() ?: return
|
||||||
existing = content
|
existing = content
|
||||||
|
existingCreatedAt = event.createdAt
|
||||||
editingSlug = slug
|
editingSlug = slug
|
||||||
_state.value =
|
_state.value =
|
||||||
FormState(
|
FormState(
|
||||||
@@ -85,6 +88,9 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
runtime = content.runtime.orEmpty(),
|
runtime = content.runtime.orEmpty(),
|
||||||
provider = content.provider.orEmpty(),
|
provider = content.provider.orEmpty(),
|
||||||
avatarUrl = content.avatarUrl.orEmpty(),
|
avatarUrl = content.avatarUrl.orEmpty(),
|
||||||
|
description = content.description.orEmpty(),
|
||||||
|
shared = event.isShared(),
|
||||||
|
threadSessions = content.isThreadSessionPolicy(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +108,12 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
|
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
|
||||||
|
|
||||||
|
fun onDescriptionChange(v: String) = _state.update { it.copy(description = v.take(PersonaContent.DESCRIPTION_MAX_CHARS), error = null) }
|
||||||
|
|
||||||
|
fun onSharedChange(v: Boolean) = _state.update { it.copy(shared = v, error = null) }
|
||||||
|
|
||||||
|
fun onThreadSessionsChange(v: Boolean) = _state.update { it.copy(threadSessions = v, error = null) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and
|
* Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and
|
||||||
* publishes it to the Buzz relays. Calls [onDone] on the main thread on success.
|
* publishes it to the Buzz relays. Calls [onDone] on the main thread on success.
|
||||||
@@ -127,14 +139,19 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
val content =
|
val content =
|
||||||
base.copy(
|
base.copy(
|
||||||
displayName = current.displayName.trim(),
|
displayName = current.displayName.trim(),
|
||||||
systemPrompt = current.systemPrompt.blankToNull(),
|
// Buzz writes an empty prompt as "" (not absent) to keep the content hash
|
||||||
|
// stable, so a blank field keeps whichever form the persona already had.
|
||||||
|
systemPrompt = current.systemPrompt.blankToNull() ?: base.systemPrompt?.takeIf { it.isBlank() },
|
||||||
model = current.model.blankToNull(),
|
model = current.model.blankToNull(),
|
||||||
runtime = current.runtime.blankToNull(),
|
runtime = current.runtime.blankToNull(),
|
||||||
provider = current.provider.blankToNull(),
|
provider = current.provider.blankToNull(),
|
||||||
avatarUrl = current.avatarUrl.blankToNull(),
|
avatarUrl = current.avatarUrl.blankToNull(),
|
||||||
|
description = current.description.blankToNull(),
|
||||||
|
// Absent is the default `channel` policy; only `thread` is written.
|
||||||
|
sessionPolicy = if (current.threadSessions) PersonaContent.SESSION_POLICY_THREAD else null,
|
||||||
)
|
)
|
||||||
|
|
||||||
val template = PersonaEvent.build(content, slug)
|
val template = PersonaEvent.build(content, slug, shared = current.shared, priorHeadCreatedAt = existingCreatedAt)
|
||||||
account.signAndSendPrivatelyOrBroadcast(template) {
|
account.signAndSendPrivatelyOrBroadcast(template) {
|
||||||
BuzzRelayDialect.flow.value
|
BuzzRelayDialect.flow.value
|
||||||
.toList()
|
.toList()
|
||||||
@@ -161,6 +178,12 @@ class AgentPersonaEditViewModel : ViewModel() {
|
|||||||
val runtime: String = "",
|
val runtime: String = "",
|
||||||
val provider: String = "",
|
val provider: String = "",
|
||||||
val avatarUrl: String = "",
|
val avatarUrl: String = "",
|
||||||
|
/** Short public description, at most [PersonaContent.DESCRIPTION_MAX_CHARS] characters. */
|
||||||
|
val description: String = "",
|
||||||
|
/** Publish to the community catalog (`["shared","true"]`) instead of owner-only. */
|
||||||
|
val shared: Boolean = false,
|
||||||
|
/** A separate ACP conversation per channel thread (`session_policy: "thread"`). */
|
||||||
|
val threadSessions: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+62
-15
@@ -53,25 +53,32 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCanvasWriter
|
||||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_body_label
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_body_label
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_conflict
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_edit
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_edit
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_empty
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_empty
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_head_in_future
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save_failed
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_title
|
import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
|
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer
|
import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -87,8 +94,10 @@ import kotlinx.coroutines.withContext
|
|||||||
* re-composes off `canvasUpdates` when a newer revision lands.
|
* re-composes off `canvasUpdates` when a newer revision lands.
|
||||||
*
|
*
|
||||||
* The edit FAB flips into a plain markdown editor; saving publishes a fresh [CanvasEvent] to the
|
* The edit FAB flips into a plain markdown editor; saving publishes a fresh [CanvasEvent] to the
|
||||||
* channel's host [relayUrl] (last-write-wins on the relay too), and the consume path folds the new
|
* channel's host [relayUrl] through [BuzzCanvasWriter], which asserts the revision the editor was
|
||||||
* revision back into [BuzzWorkspaceStates] so the view updates without a manual refresh.
|
* opened on (`expected-revision`) so a concurrent edit is refused by the relay instead of silently
|
||||||
|
* overwritten. An accepted revision is folded back into [BuzzWorkspaceStates] so the view updates
|
||||||
|
* without a manual refresh; a conflict keeps the editor open with the draft intact.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BuzzCanvasScreen(
|
fun BuzzCanvasScreen(
|
||||||
@@ -119,15 +128,17 @@ fun BuzzCanvasScreen(
|
|||||||
// that Buzz's own client would never show.
|
// that Buzz's own client would never show.
|
||||||
val canEdit = channel?.event?.isBuzzDm() != true
|
val canEdit = channel?.event?.isBuzzDm() != true
|
||||||
|
|
||||||
var editing by remember { mutableStateOf(false) }
|
// The head the editor was opened on, snapshotted at edit start (not the live head): a newer
|
||||||
|
// revision landing while the editor is open must surface as a conflict, not be overwritten.
|
||||||
|
var editBase by remember { mutableStateOf<CanvasEditBase?>(null) }
|
||||||
|
|
||||||
if (editing) {
|
editBase?.let { base ->
|
||||||
CanvasEditor(
|
CanvasEditor(
|
||||||
channelId = channelId,
|
channelId = channelId,
|
||||||
relayUrl = relayUrl,
|
relayUrl = relayUrl,
|
||||||
initial = content.orEmpty(),
|
base = base,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onClose = { editing = false },
|
onClose = { editBase = null },
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -160,7 +171,10 @@ fun BuzzCanvasScreen(
|
|||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (canEdit) {
|
if (canEdit) {
|
||||||
FloatingActionButton(onClick = { editing = true }, shape = CircleShape) {
|
FloatingActionButton(
|
||||||
|
onClick = { editBase = CanvasEditBase(content.orEmpty(), canvas?.idHex, canvas?.createdAt()) },
|
||||||
|
shape = CircleShape,
|
||||||
|
) {
|
||||||
Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(Res.string.buzz_canvas_edit))
|
Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(Res.string.buzz_canvas_edit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,17 +221,24 @@ fun BuzzCanvasScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The canvas revision an edit started from: its text, and its id/created_at (null when there was no canvas). */
|
||||||
|
private class CanvasEditBase(
|
||||||
|
val content: String,
|
||||||
|
val headId: HexKey?,
|
||||||
|
val headCreatedAt: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
/** The markdown editor: a full-height text field for the canvas body with a Save action. */
|
/** The markdown editor: a full-height text field for the canvas body with a Save action. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun CanvasEditor(
|
private fun CanvasEditor(
|
||||||
channelId: String,
|
channelId: String,
|
||||||
relayUrl: String,
|
relayUrl: String,
|
||||||
initial: String,
|
base: CanvasEditBase,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var text by remember { mutableStateOf(initial) }
|
var text by remember { mutableStateOf(base.content) }
|
||||||
var saving by remember { mutableStateOf(false) }
|
var saving by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -249,15 +270,41 @@ private fun CanvasEditor(
|
|||||||
error = null
|
error = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
val outcome =
|
||||||
accountViewModel.account.signAndSendPrivatelyOrBroadcast(
|
withContext(Dispatchers.IO) {
|
||||||
CanvasEvent.build(channelId, text),
|
BuzzCanvasWriter.save(
|
||||||
) { listOf(relay) }
|
account = accountViewModel.account,
|
||||||
|
relay = relay,
|
||||||
|
channelId = channelId,
|
||||||
|
markdown = text,
|
||||||
|
headId = base.headId,
|
||||||
|
headCreatedAt = base.headCreatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (outcome) {
|
||||||
|
is BuzzCanvasWriter.Outcome.Saved -> {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
is BuzzCanvasWriter.Outcome.Conflict -> {
|
||||||
|
saving = false
|
||||||
|
error = loadStringRes(Res.string.buzz_canvas_conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
BuzzCanvasWriter.Outcome.HeadTooFarInFuture -> {
|
||||||
|
saving = false
|
||||||
|
error = loadStringRes(Res.string.buzz_canvas_head_in_future)
|
||||||
|
}
|
||||||
|
|
||||||
|
is BuzzCanvasWriter.Outcome.Failed -> {
|
||||||
|
saving = false
|
||||||
|
error = loadStringRes(Res.string.buzz_canvas_save_failed, outcome.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onClose()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
saving = false
|
saving = false
|
||||||
error = "Failed to save: ${e.message ?: e::class.simpleName}"
|
error = loadStringRes(Res.string.buzz_canvas_save_failed, e.message ?: e::class.simpleName ?: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+7
@@ -32,7 +32,9 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
|||||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName
|
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_admin_kick
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_archived
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_archived
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_auto_archived
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_created
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_created
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_deleted
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_deleted
|
||||||
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_unarchived
|
import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_unarchived
|
||||||
@@ -179,6 +181,11 @@ fun buzzSystemMessageText(
|
|||||||
|
|
||||||
SystemMessagePayload.DM_CREATED -> stringRes(Res.string.buzz_system_dm_created, actor)
|
SystemMessagePayload.DM_CREATED -> stringRes(Res.string.buzz_system_dm_created, actor)
|
||||||
|
|
||||||
|
// Relay-authored with no actor: the administrator who acted is deliberately not named.
|
||||||
|
SystemMessagePayload.ADMIN_KICK -> stringRes(Res.string.buzz_system_admin_kick, target)
|
||||||
|
|
||||||
|
SystemMessagePayload.CHANNEL_AUTO_ARCHIVED -> stringRes(Res.string.buzz_system_channel_auto_archived)
|
||||||
|
|
||||||
else -> stringRes(Res.string.buzz_system_unknown, actor, payload.type.replace('_', ' '))
|
else -> stringRes(Res.string.buzz_system_unknown, actor, payload.type.replace('_', ' '))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -218,7 +218,7 @@ private fun RelayGroupMembers(
|
|||||||
if (iCanModerate) {
|
if (iCanModerate) {
|
||||||
AddMemberBar(
|
AddMemberBar(
|
||||||
isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE },
|
isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE },
|
||||||
onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) },
|
onAdd = { accountViewModel.addRelayGroupUser(channel, it) },
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -1876,6 +1876,11 @@ class AccountViewModel(
|
|||||||
pubkey: HexKey,
|
pubkey: HexKey,
|
||||||
) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) }
|
) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) }
|
||||||
|
|
||||||
|
fun addRelayGroupUser(
|
||||||
|
channel: RelayGroupChannel,
|
||||||
|
pubkey: HexKey,
|
||||||
|
) = launchSigner { account.relayGroups.addRelayGroupUser(channel, pubkey) }
|
||||||
|
|
||||||
fun putRelayGroupUser(
|
fun putRelayGroupUser(
|
||||||
channel: RelayGroupChannel,
|
channel: RelayGroupChannel,
|
||||||
pubkey: HexKey,
|
pubkey: HexKey,
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ hand-transcribed schemas:
|
|||||||
Each feature is a sub-package following the standard Quartz per-NIP shape (`<Feature>Event`
|
Each feature is a sub-package following the standard Quartz per-NIP shape (`<Feature>Event`
|
||||||
with a `KIND` companion, a `tags/` folder of tag classes, `TagArrayBuilderExt` write-DSL
|
with a `KIND` companion, a `tags/` folder of tag classes, `TagArrayBuilderExt` write-DSL
|
||||||
verbs, and `TagArrayExt` read accessors), mirroring e.g. `nip88Polls`. There is one event
|
verbs, and `TagArrayExt` read accessors), mirroring e.g. `nip88Polls`. There is one event
|
||||||
class per kind, ~78 in total, each registered in `utils/EventFactory.kt` (except the
|
class per kind, ~84 in total, each registered in `utils/EventFactory.kt` (except the
|
||||||
conflicts below).
|
conflicts below).
|
||||||
|
|
||||||
| Package | Buzz NIP | Kind(s) |
|
| Package | Buzz NIP | Kind(s) |
|
||||||
@@ -86,15 +86,61 @@ conflicts below).
|
|||||||
| `aoObserver` | NIP-AO | 24200 |
|
| `aoObserver` | NIP-AO | 24200 |
|
||||||
| `aeEngrams` | NIP-AE | 30174 |
|
| `aeEngrams` | NIP-AE | 30174 |
|
||||||
| `apPersonas` / `teams` / `managedAgents` / `agentProfiles` | NIP-AP + agent identity | 30175 / 30176 / 30177 / 10100 |
|
| `apPersonas` / `teams` / `managedAgents` / `agentProfiles` | NIP-AP + agent identity | 30175 / 30176 / 30177 / 10100 |
|
||||||
|
| `teamCatalog` | NIP-AP team catalog (shared projection with embedded members) | 30178 |
|
||||||
| `erReminders` / `plPushLease` / `dvDmVisibility` / `wpWorkspaceProfile` | NIP-ER/PL/DV/WP | 30300 / 30350 / 30622 / 9033 |
|
| `erReminders` / `plPushLease` / `dvDmVisibility` / `wpWorkspaceProfile` | NIP-ER/PL/DV/WP | 30300 / 30350 / 30622 / 9033 |
|
||||||
| `iaIdentityArchival` / `cwChannelWindow` | NIP-IA / NIP-CW | 9035/9036/8002/8003/13535 / 39005/39006 |
|
| `iaIdentityArchival` / `cwChannelWindow` | NIP-IA / NIP-CW | 9035/9036/8002/8003/13535 / 39005/39006/39007 |
|
||||||
| `relayAdmin` / `moderation` | admin + moderation | 9030-9032 / 9040-9044, 42000 |
|
| `relayAdmin` / `moderation` | admin + moderation | 9030-9032 / 9040-9044, 42000 |
|
||||||
| `stream` / `stream.sidecars` | stream messaging | 40002-40008, 40099, 40100 / 40901, 40902 |
|
| `stream` / `stream.sidecars` | stream messaging | 40002-40008, 40099, 40100 / 40901, 40902 |
|
||||||
| `dm` / `jobs` | DMs / agent jobs | 41001, 41010-41012 / 43001-43006 |
|
| `dm` / `jobs` | DMs / agent jobs | 41001, 41010-41012 / 43001-43006 |
|
||||||
| `workflow` / `forum` / `notifications` | workflow + social | 30620, 46001-46031 / 45001-45003 / 44100, 44101 |
|
| `workflow` / `forum` / `notifications` | workflow + social | 30620, 46001-46031 / 45001-45003 / 44100, 44101 |
|
||||||
| `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 / 24134 / 48001 / 49001 |
|
| `arArtifacts` | NIP-AR channel artifacts (revision chain by `prev`) | 45010, 45011 (relay-signed removal) |
|
||||||
|
| `mpProjects` | NIP-MP multi-repo projects (global, not `h`-scoped) | 30621 |
|
||||||
|
| `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 (48104 = relay-synthesized liveness) / 24134 / 48001 / 49001 |
|
||||||
| `rsReadState` | NIP-RS | (helpers on `AppSpecificDataEvent`, kind 30078) |
|
| `rsReadState` | NIP-RS | (helpers on `AppSpecificDataEvent`, kind 30078) |
|
||||||
|
|
||||||
|
Not modelled on purpose: **NIP-PMA `kind:30179`** (private managed agent) is only reserved upstream
|
||||||
|
— its spec says relays MUST reject it for now — and **NIP-FI** federated identity is an HTTP-upgrade
|
||||||
|
header, not an event.
|
||||||
|
|
||||||
|
### Upstream sync
|
||||||
|
|
||||||
|
Last reconciled against `block/buzz` `4ef23609b` (2026-09-29); the previous full sync was
|
||||||
|
`03fe19d6` (2026-07-21). Behaviour picked up in that pass, beyond the new kinds above:
|
||||||
|
|
||||||
|
- **Read gates.** Personas (30175) and the team catalog (30178) are author-only unless the event
|
||||||
|
carries exactly `["shared","true"]` (`apPersonas/tags/SharedTag`); a malformed `shared` tag is
|
||||||
|
rejected at ingest. Persona content gained `acp_command`, `description` and `session_policy`
|
||||||
|
(upstream field order kept, so content bytes and hashes match), and a shared head publishes only a
|
||||||
|
portable harness alias (`PersonaContent.forSharedCatalog`).
|
||||||
|
- **NIP-OA time bounds** are enforced at admission against the AUTH event's `created_at`
|
||||||
|
(strictly, every clause, `kind=` ignored): `OwnerAttestation.verifyForAuthAt`. Owner keys and
|
||||||
|
signatures must be lowercase hex, and the `auth` tag exactly four elements.
|
||||||
|
- **NIP-10 threading** follows `buzz-core/src/nip10.rs`: markers need 4+ elements and a 64-hex id,
|
||||||
|
the last one wins, and a lone `root` marker is top-level (`threading/BuzzThreadMarkers`). A reply's
|
||||||
|
root must be derived with `buzzThreadRootForReplyTo` or the relay rejects it.
|
||||||
|
- **Channel messages are written as kind 9** in Buzz's tag shape (`stream/BuzzChatMessage`, mirroring
|
||||||
|
`build_message`): `h`, NIP-10 thread markers, `p` mentions, `broadcast` for a reply that also shows
|
||||||
|
in the channel. That is what Buzz's own clients write; kind 40002 is read-only now, kept so older
|
||||||
|
messages still render. `broadcast` means the same on either kind.
|
||||||
|
- **Edits (40003)** apply to any channel message kind. The relay stores them as-is; clients resolve
|
||||||
|
them: the newest edit by the message's author, or by the owner that author declares through a
|
||||||
|
verified NIP-OA `auth` tag on its kind-0 profile (`OwnerAttestation.verifiedOwnerOf`), wins. A
|
||||||
|
relay-signed message counts as written by its `actor` (else `p`). The edit's tags overlay the
|
||||||
|
original's (`stream/BuzzEditTagOverlay`): attachments only from the edit, custom emoji from the
|
||||||
|
edit when it has any, added `p` mentions merged. So an edit re-sends every attachment and emoji
|
||||||
|
the new text still uses.
|
||||||
|
- **Contextual identity names** (`identityNames/IdentityNamePolicy`, Buzz's portable v1 contract,
|
||||||
|
run against its vendored fixtures in `IdentityNamePolicyTest`): within one context (a channel's
|
||||||
|
members) a name only grows on a real collision: a person keeps it, an agent becomes `Alice’s Honey`
|
||||||
|
or `Honey (agent)`, and anyone still colliding gets an npub suffix. `commons/.../BuzzIdentityNames`
|
||||||
|
applies it per relay group; agents are authors with a 10100 profile or a verified NIP-OA owner.
|
||||||
|
- **Put-user (9000)** without a `role` tag is "no role change" on Buzz; a plain add sends none.
|
||||||
|
- **Compare-and-swap writes**: canvas (40100) and workflow definitions (30620) take
|
||||||
|
`["expected-revision", <head id>]` and the relay answers `conflict:` on a stale head. A workflow's
|
||||||
|
`d` must be a UUID.
|
||||||
|
- A REQ that hits the relay's query deadline ends with `CLOSED "error: query timed out"`, which the
|
||||||
|
pool treats as transient rather than a structural refusal.
|
||||||
|
|
||||||
### Kind conflicts — where a Buzz kind number is already owned
|
### Kind conflicts — where a Buzz kind number is already owned
|
||||||
|
|
||||||
Several Buzz kind numbers collide with an existing Amethyst/Nostr class. `EventFactory`
|
Several Buzz kind numbers collide with an existing Amethyst/Nostr class. `EventFactory`
|
||||||
@@ -221,7 +267,7 @@ a Buzz relay once a canvas has arrived.
|
|||||||
The **edit composer (40003)** is wired: `ChannelNewMessageViewModel` has a Buzz edit mode
|
The **edit composer (40003)** is wired: `ChannelNewMessageViewModel` has a Buzz edit mode
|
||||||
(`editBuzzMessage`/`clearBuzzEdit`) whose next send publishes a 40003 targeting the
|
(`editBuzzMessage`/`clearBuzzEdit`) whose next send publishes a 40003 targeting the
|
||||||
original (minimal, mirroring Buzz's `build_edit`); an "Edit" action gated to the user's
|
original (minimal, mirroring Buzz's `build_edit`); an "Edit" action gated to the user's
|
||||||
own 40002 messages threads to the composer through the shared chat feed via an optional
|
own messages (kind 9 on a Buzz relay, or legacy 40002) threads to the composer through the shared chat feed via an optional
|
||||||
`onWantsToEditBuzz` callback (default-null, so no other chat surface is affected), and
|
`onWantsToEditBuzz` callback (default-null, so no other chat surface is affected), and
|
||||||
`EditFieldRow` shows an editing banner. This closes the render↔create loop (edit overlays
|
`EditFieldRow` shows an editing banner. This closes the render↔create loop (edit overlays
|
||||||
already rendered).
|
already rendered).
|
||||||
|
|||||||
+4
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.quartz.buzz.agentProfiles
|
package com.vitorpamplona.quartz.buzz.agentProfiles
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
@@ -75,6 +76,9 @@ class AgentProfileEvent(
|
|||||||
companion object {
|
companion object {
|
||||||
const val KIND = 10100
|
const val KIND = 10100
|
||||||
|
|
||||||
|
/** The replaceable address of [pubKey]'s agent profile (`10100:<pubkey>:`). */
|
||||||
|
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "")
|
||||||
|
|
||||||
fun build(
|
fun build(
|
||||||
profile: AgentProfileContent,
|
profile: AgentProfileContent,
|
||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
|||||||
+41
@@ -36,6 +36,11 @@ import kotlinx.serialization.json.Json
|
|||||||
* on purpose — `null` means "the harness did not report this", not zero. Unknown JSON
|
* on purpose — `null` means "the harness did not report this", not zero. Unknown JSON
|
||||||
* fields are ignored for forward compatibility, and an unrecognized [stopReason] is
|
* fields are ignored for forward compatibility, and an unrecognized [stopReason] is
|
||||||
* kept verbatim (map it with [stopReasonOrUnknown]) rather than dropping the payload.
|
* kept verbatim (map it with [stopReasonOrUnknown]) rather than dropping the payload.
|
||||||
|
*
|
||||||
|
* [pricingIdentity] is optional but not nullable on the wire: it is present only when the
|
||||||
|
* publisher could prove the billing authority and the actually-requested model, and its absence
|
||||||
|
* means "price unknown" — never infer a price from [model], which stays the configured/session
|
||||||
|
* model.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AgentTurnMetricPayload(
|
data class AgentTurnMetricPayload(
|
||||||
@@ -50,6 +55,7 @@ data class AgentTurnMetricPayload(
|
|||||||
val cumulative: TokenCounts? = null,
|
val cumulative: TokenCounts? = null,
|
||||||
val deltaReliable: Boolean = true,
|
val deltaReliable: Boolean = true,
|
||||||
val stopReason: String? = null,
|
val stopReason: String? = null,
|
||||||
|
val pricingIdentity: PricingIdentity? = null,
|
||||||
) {
|
) {
|
||||||
/** Maps [stopReason] to a [StopReason], treating any unrecognized value as [StopReason.UNKNOWN]. */
|
/** Maps [stopReason] to a [StopReason], treating any unrecognized value as [StopReason.UNKNOWN]. */
|
||||||
fun stopReasonOrUnknown(): StopReason? = stopReason?.let { StopReason.fromWire(it) }
|
fun stopReasonOrUnknown(): StopReason? = stopReason?.let { StopReason.fromWire(it) }
|
||||||
@@ -79,9 +85,44 @@ data class AgentTurnMetricPayload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The billing identity of a turn (NIP-AM `pricingIdentity`), mirroring `PricingIdentity` in
|
||||||
|
* `agent_turn_metric.rs`. When present, [authority] and [model] are required strings; [cacheClass]
|
||||||
|
* is omitted (never null) when not applicable.
|
||||||
|
*
|
||||||
|
* Pricing lookup is an exact string match on `(authority, model)`. The Rust parser does not
|
||||||
|
* restrict [authority] to the registered set, so neither does this one — check
|
||||||
|
* [isRegisteredAuthority] before trusting it for a price.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PricingIdentity(
|
||||||
|
/** The billing namespace: an exact lowercase hostname such as `api.anthropic.com` (not the transport provider). */
|
||||||
|
val authority: String,
|
||||||
|
/** The actually-requested billable model id, as resolved at request time. */
|
||||||
|
val model: String,
|
||||||
|
/** The cache-write class (e.g. `ephemeral`), when applicable. */
|
||||||
|
val cacheClass: String? = null,
|
||||||
|
) {
|
||||||
|
/** True when [authority] is one of the NIP's registered billing namespaces. */
|
||||||
|
fun isRegisteredAuthority(): Boolean = authority in REGISTERED_AUTHORITIES
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val AUTHORITY_ANTHROPIC = "api.anthropic.com"
|
||||||
|
const val AUTHORITY_OPENAI = "api.openai.com"
|
||||||
|
const val AUTHORITY_OPENROUTER = "openrouter.ai"
|
||||||
|
|
||||||
|
/** The registered `authority` values; the set only grows by amendment to NIP-AM. */
|
||||||
|
val REGISTERED_AUTHORITIES = setOf(AUTHORITY_ANTHROPIC, AUTHORITY_OPENAI, AUTHORITY_OPENROUTER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token usage counts. All fields nullable — `null` distinguishes "not reported" from
|
* Token usage counts. All fields nullable — `null` distinguishes "not reported" from
|
||||||
* zero. Mirrors `TokenCounts` in `agent_turn_metric.rs`.
|
* zero. Mirrors `TokenCounts` in `agent_turn_metric.rs`.
|
||||||
|
*
|
||||||
|
* The cache components ([cacheReadTokens], [cacheWriteTokens]) are informational subsets of
|
||||||
|
* [inputTokens]. NIP-AM has publishers keep an explicit zero and *omit* a component they cannot
|
||||||
|
* see, so an absent cache field is "unknown" — never sum it as zero.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TokenCounts(
|
data class TokenCounts(
|
||||||
|
|||||||
+50
@@ -47,6 +47,9 @@ import kotlinx.serialization.json.Json
|
|||||||
data class PersonaContent(
|
data class PersonaContent(
|
||||||
@SerialName("display_name") val displayName: String,
|
@SerialName("display_name") val displayName: String,
|
||||||
@SerialName("system_prompt") val systemPrompt: String? = null,
|
@SerialName("system_prompt") val systemPrompt: String? = null,
|
||||||
|
// The ACP harness command the agent runs under. On a shared (catalog) head it carries only a
|
||||||
|
// portable alias - see [forSharedCatalog].
|
||||||
|
@SerialName("acp_command") val acpCommand: String? = null,
|
||||||
@SerialName("avatar_url") val avatarUrl: String? = null,
|
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||||
val runtime: String? = null,
|
val runtime: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
@@ -58,9 +61,34 @@ data class PersonaContent(
|
|||||||
@SerialName("respond_to") val respondTo: String? = null,
|
@SerialName("respond_to") val respondTo: String? = null,
|
||||||
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("respond_to_allowlist") val respondToAllowlist: List<String> = emptyList(),
|
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("respond_to_allowlist") val respondToAllowlist: List<String> = emptyList(),
|
||||||
val parallelism: Int? = null,
|
val parallelism: Int? = null,
|
||||||
|
// Appended after the original fields upstream so a record without them serializes
|
||||||
|
// byte-identically to the pre-revision era. [description] is short PUBLIC display text
|
||||||
|
// (max [DESCRIPTION_MAX_CHARS]); [sessionPolicy] is omitted for the default `channel`.
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("session_policy") val sessionPolicy: String? = null,
|
||||||
) {
|
) {
|
||||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||||
|
|
||||||
|
/** True when the agent keeps a separate ACP conversation per channel thread. */
|
||||||
|
fun isThreadSessionPolicy() = sessionPolicy == SESSION_POLICY_THREAD
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The projection Buzz publishes on a shared (`["shared","true"]`) head: a catalog reader on
|
||||||
|
* another machine can only run a portable harness alias, so an unset command becomes the
|
||||||
|
* explicit stock [DEFAULT_ACP_COMMAND] (distinguishing a reset from an omitted override) and
|
||||||
|
* a machine-local command is dropped. Mirrors `build_persona_event` in Buzz's
|
||||||
|
* `desktop/src-tauri/src/managed_agents/persona_events.rs`.
|
||||||
|
*/
|
||||||
|
fun forSharedCatalog(): PersonaContent =
|
||||||
|
copy(
|
||||||
|
acpCommand =
|
||||||
|
when {
|
||||||
|
acpCommand == null -> DEFAULT_ACP_COMMAND
|
||||||
|
isPortableAcpCommand(acpCommand) -> acpCommand
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val JSON =
|
val JSON =
|
||||||
Json {
|
Json {
|
||||||
@@ -70,5 +98,27 @@ data class PersonaContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun decodeFromJson(json: String): PersonaContent = JSON.decodeFromString(json)
|
fun decodeFromJson(json: String): PersonaContent = JSON.decodeFromString(json)
|
||||||
|
|
||||||
|
/** Buzz's stock ACP harness (`DEFAULT_ACP_COMMAND` in `managed_agents/types.rs`). */
|
||||||
|
const val DEFAULT_ACP_COMMAND = "buzz-acp"
|
||||||
|
|
||||||
|
/** The only non-default [sessionPolicy] value; anything else reads as `channel`. */
|
||||||
|
const val SESSION_POLICY_THREAD = "thread"
|
||||||
|
|
||||||
|
/** Upper bound Buzz puts on [description]. */
|
||||||
|
const val DESCRIPTION_MAX_CHARS = 280
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A harness command another machine can run: the stock `buzz-acp`, or `buzz-<name>-acp`
|
||||||
|
* with `<name>` of ASCII letters, digits, `-` or `_`, at most 255 bytes in all. Mirrors
|
||||||
|
* `is_portable_acp_command` in Buzz's `managed_agents/backend.rs`.
|
||||||
|
*/
|
||||||
|
fun isPortableAcpCommand(command: String): Boolean {
|
||||||
|
if (command == DEFAULT_ACP_COMMAND) return true
|
||||||
|
if (command.length > 255 || !command.startsWith("buzz-") || !command.endsWith("-acp")) return false
|
||||||
|
if (command.length <= "buzz-".length + "-acp".length) return false
|
||||||
|
val name = command.substring("buzz-".length, command.length - "-acp".length)
|
||||||
|
return name.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '-' || it == '_' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-2
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.quartz.buzz.apPersonas
|
package com.vitorpamplona.quartz.buzz.apPersonas
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.apPersonas.tags.SharedTag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
@@ -39,6 +40,11 @@ import kotlinx.coroutines.CancellationException
|
|||||||
*
|
*
|
||||||
* The `content` is a plaintext JSON [PersonaContent]. Ground truth for the content projection
|
* The `content` is a plaintext JSON [PersonaContent]. Ground truth for the content projection
|
||||||
* is `desktop/src-tauri/src/managed_agents/persona_events.rs`.
|
* is `desktop/src-tauri/src/managed_agents/persona_events.rs`.
|
||||||
|
*
|
||||||
|
* Read access is **author-only unless shared**: the relay serves a persona to anyone but its
|
||||||
|
* author only when it carries exactly `["shared","true"]` ([SharedTag]); otherwise it is
|
||||||
|
* silently withheld from foreign REQs, COUNTs and id lookups. Device sync reads
|
||||||
|
* `authors:[self]`, so the owner always sees their own.
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
class PersonaEvent(
|
class PersonaEvent(
|
||||||
@@ -63,6 +69,9 @@ class PersonaEvent(
|
|||||||
/** The persona slug — the `d` tag. */
|
/** The persona slug — the `d` tag. */
|
||||||
fun slug() = dTag()
|
fun slug() = dTag()
|
||||||
|
|
||||||
|
/** True when the persona is published to the community catalog (`["shared","true"]`). */
|
||||||
|
fun isShared() = SharedTag.isShared(tags)
|
||||||
|
|
||||||
/** Parses the persona configuration, or throws if the JSON is malformed. */
|
/** Parses the persona configuration, or throws if the JSON is malformed. */
|
||||||
fun persona(): PersonaContent = PersonaContent.decodeFromJson(content)
|
fun persona(): PersonaContent = PersonaContent.decodeFromJson(content)
|
||||||
|
|
||||||
@@ -77,14 +86,31 @@ class PersonaEvent(
|
|||||||
companion object {
|
companion object {
|
||||||
const val KIND = 30175
|
const val KIND = 30175
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a persona the way Buzz's `build_persona_event` does: a `d` slug tag, plus
|
||||||
|
* `["shared","true"]` when [shared]. A shared head publishes the portable catalog
|
||||||
|
* projection of its content ([PersonaContent.forSharedCatalog]).
|
||||||
|
*
|
||||||
|
* An edit should pass [priorHeadCreatedAt] (the replaced head's `created_at`) so the new
|
||||||
|
* head sorts after it even when this clock lags (`monotonic_created_at` upstream).
|
||||||
|
*/
|
||||||
fun build(
|
fun build(
|
||||||
persona: PersonaContent,
|
persona: PersonaContent,
|
||||||
slug: String,
|
slug: String,
|
||||||
createdAt: Long = TimeUtils.now(),
|
shared: Boolean = false,
|
||||||
|
priorHeadCreatedAt: Long? = null,
|
||||||
|
createdAt: Long = monotonicCreatedAt(priorHeadCreatedAt),
|
||||||
initializer: TagArrayBuilder<PersonaEvent>.() -> Unit = {},
|
initializer: TagArrayBuilder<PersonaEvent>.() -> Unit = {},
|
||||||
) = eventTemplate<PersonaEvent>(KIND, persona.encodeToJson(), createdAt) {
|
) = eventTemplate<PersonaEvent>(KIND, (if (shared) persona.forSharedCatalog() else persona).encodeToJson(), createdAt) {
|
||||||
dTag(slug)
|
dTag(slug)
|
||||||
|
if (shared) addUnique(SharedTag.assemble())
|
||||||
initializer()
|
initializer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `max(now, prior + 1)`: a replacement head never sorts behind the one it replaces. */
|
||||||
|
fun monotonicCreatedAt(priorHeadCreatedAt: Long?): Long {
|
||||||
|
val now = TimeUtils.now()
|
||||||
|
return if (priorHeadCreatedAt == null) now else maxOf(now, priorHeadCreatedAt + 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.apPersonas.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog opt-in on Buzz's shared-gated kinds (the persona `kind:30175` and the team
|
||||||
|
* catalog `kind:30178`): exactly `["shared","true"]`. Without it the relay serves the event
|
||||||
|
* to its author only; with it, to the whole community. It is a tag rather than a content field
|
||||||
|
* so toggling it leaves the content bytes (and the persona content hash) unchanged.
|
||||||
|
*
|
||||||
|
* The relay refuses any other `shared` shape at ingest (a different value, a third element, or
|
||||||
|
* a second `shared` tag), and its read gate fails closed on them. Ground truth:
|
||||||
|
* `event_is_shared` in Buzz's `buzz-core/src/kind.rs` and `validate_shared_tag` in
|
||||||
|
* `buzz-relay/src/handlers/ingest.rs`.
|
||||||
|
*/
|
||||||
|
object SharedTag {
|
||||||
|
const val TAG_NAME = "shared"
|
||||||
|
const val VALUE = "true"
|
||||||
|
|
||||||
|
fun assemble() = arrayOf(TAG_NAME, VALUE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only when [tags] carry exactly one `shared` tag and it is exactly `["shared","true"]`;
|
||||||
|
* any malformed or duplicated `shared` tag reads as not shared, like the relay's read gate.
|
||||||
|
*/
|
||||||
|
fun isShared(tags: TagArray): Boolean {
|
||||||
|
var count = 0
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.isEmpty() || tag[0] != TAG_NAME) continue
|
||||||
|
if (tag.size != 2 || tag[1] != VALUE) return false
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastAny
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authoritative NIP-AR envelope of a revision, independent of any client's content
|
||||||
|
* schema. Mirrors `ArtifactEnvelope` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ArtifactEnvelope(
|
||||||
|
/** Community-local stable identity — the `d` UUID. */
|
||||||
|
val id: String,
|
||||||
|
/** Home channel — the `h` UUID. */
|
||||||
|
val home: String,
|
||||||
|
/** Immutable namespaced type name. */
|
||||||
|
val type: String,
|
||||||
|
val op: ArtifactOp,
|
||||||
|
/** The revision this one replaces; null exactly on `create`. */
|
||||||
|
val prev: HexKey?,
|
||||||
|
/** Optional conversation anchor. */
|
||||||
|
val root: HexKey?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The outcome of [ArtifactValidator.validate]. */
|
||||||
|
sealed interface ArtifactValidation {
|
||||||
|
data class Valid(
|
||||||
|
val envelope: ArtifactEnvelope,
|
||||||
|
) : ArtifactValidation
|
||||||
|
|
||||||
|
/** [reason] is upstream's rejection message, verbatim. */
|
||||||
|
data class Invalid(
|
||||||
|
val reason: String,
|
||||||
|
) : ArtifactValidation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-AR envelope validation — a line-for-line port of `validate` in Buzz's
|
||||||
|
* `buzz-core/src/artifact.rs`, which the relay runs on every `kind:45010` before
|
||||||
|
* authorization. Payloads (content and client tags) stay opaque; NIP-OA `auth` tag
|
||||||
|
* verification is left to the relay, as upstream does.
|
||||||
|
*/
|
||||||
|
object ArtifactValidator {
|
||||||
|
/** Maximum artifact tags, including the envelope. */
|
||||||
|
const val MAX_TAGS = 256
|
||||||
|
|
||||||
|
/** Maximum UTF-8 bytes in a tag name. */
|
||||||
|
const val MAX_TAG_NAME_BYTES = 128
|
||||||
|
|
||||||
|
/** Maximum UTF-8 bytes in each tag element. */
|
||||||
|
const val MAX_TAG_VALUE_BYTES = 4096
|
||||||
|
|
||||||
|
/** Maximum UTF-8 bytes across all tag elements. */
|
||||||
|
const val MAX_TAG_BYTES = 65536
|
||||||
|
|
||||||
|
/** The NIP-OA attestation tag, the only non-envelope tag a delete may carry. */
|
||||||
|
const val AUTH_TAG_NAME = "auth"
|
||||||
|
|
||||||
|
/** Envelope tag names: each at most once, with exactly two elements. */
|
||||||
|
val ENVELOPE_TAG_NAMES =
|
||||||
|
setOf(
|
||||||
|
VersionTag.TAG_NAME,
|
||||||
|
DTag.TAG_NAME,
|
||||||
|
GroupIdTag.TAG_NAME,
|
||||||
|
TypeTag.TAG_NAME,
|
||||||
|
TitleTag.TAG_NAME,
|
||||||
|
OpTag.TAG_NAME,
|
||||||
|
RootTag.TAG_NAME,
|
||||||
|
PrevTag.TAG_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun validate(
|
||||||
|
tags: TagArray,
|
||||||
|
content: String,
|
||||||
|
): ArtifactValidation {
|
||||||
|
if (tags.size > MAX_TAGS) return invalid("too many tags")
|
||||||
|
|
||||||
|
val fields = HashMap<String, String>()
|
||||||
|
var bytes = 0
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.isEmpty()) return invalid("empty tag")
|
||||||
|
val name = tag[0]
|
||||||
|
if (name.utf8Size() > MAX_TAG_NAME_BYTES) return invalid("tag name too long")
|
||||||
|
for (value in tag) {
|
||||||
|
val size = value.utf8Size()
|
||||||
|
bytes += size
|
||||||
|
if (size > MAX_TAG_VALUE_BYTES) return invalid("tag value too long")
|
||||||
|
}
|
||||||
|
if (name in ENVELOPE_TAG_NAMES && (tag.size != 2 || fields.put(name, tag[1]) != null)) {
|
||||||
|
return invalid("envelope tags must occur once with exactly two elements")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bytes > MAX_TAG_BYTES) return invalid("total tag bytes exceeded")
|
||||||
|
|
||||||
|
val version = fields[VersionTag.TAG_NAME] ?: return missing()
|
||||||
|
if (version != VersionTag.CURRENT) return invalid("unsupported artifact envelope version")
|
||||||
|
|
||||||
|
val id = fields[DTag.TAG_NAME] ?: return missing()
|
||||||
|
if (!ArtifactIds.isCanonicalUuid(id)) return invalidUuid(id)
|
||||||
|
val home = fields[GroupIdTag.TAG_NAME] ?: return missing()
|
||||||
|
if (!ArtifactIds.isCanonicalUuid(home)) return invalidUuid(home)
|
||||||
|
|
||||||
|
val type = fields[TypeTag.TAG_NAME] ?: return missing()
|
||||||
|
if (!TypeTag.isValid(type)) return invalid("invalid namespaced artifact type")
|
||||||
|
|
||||||
|
val opCode = fields[OpTag.TAG_NAME] ?: return missing()
|
||||||
|
val op = ArtifactOp.parse(opCode) ?: return invalid("invalid artifact operation")
|
||||||
|
|
||||||
|
val prev = fields[PrevTag.TAG_NAME]
|
||||||
|
if (prev != null && !ArtifactIds.isEventId(prev)) return invalid("event ID must be 64 lowercase hex characters")
|
||||||
|
if ((op == ArtifactOp.CREATE) != (prev == null)) return invalid("prev required exactly on non-create revisions")
|
||||||
|
|
||||||
|
val root = fields[RootTag.TAG_NAME]
|
||||||
|
if (root != null && !ArtifactIds.isEventId(root)) return invalid("event ID must be 64 lowercase hex characters")
|
||||||
|
|
||||||
|
if (op == ArtifactOp.DELETE) {
|
||||||
|
if (TitleTag.TAG_NAME in fields || content.isNotEmpty()) {
|
||||||
|
return invalid("delete must omit title and have empty content")
|
||||||
|
}
|
||||||
|
if (tags.fastAny { it[0] !in ENVELOPE_TAG_NAMES && it[0] != AUTH_TAG_NAME }) {
|
||||||
|
return invalid("delete allows only envelope and verified auth tags")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val title = fields[TitleTag.TAG_NAME] ?: return missing()
|
||||||
|
if (!TitleTag.isValid(title)) return invalid("title must be nonblank and at most 512 UTF-8 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ArtifactValidation.Valid(ArtifactEnvelope(id, home, type, op, prev, root))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.utf8Size() = encodeToByteArray().size
|
||||||
|
|
||||||
|
private fun invalid(reason: String) = ArtifactValidation.Invalid(reason)
|
||||||
|
|
||||||
|
private fun missing() = invalid("missing required envelope tag")
|
||||||
|
|
||||||
|
// `canonical_uuid` distinguishes an unparseable value from a non-canonical one.
|
||||||
|
private fun invalidUuid(value: String) =
|
||||||
|
if (UUID_ANY_FORM.matches(value)) {
|
||||||
|
invalid("UUID must be canonical lowercase and non-nil")
|
||||||
|
} else {
|
||||||
|
invalid("invalid UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val UUID_ANY_FORM = Regex("^(urn:uuid:)?\\{?[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}}?$")
|
||||||
|
}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz NIP-AR artifact revision (`kind:45010`): one complete snapshot of an editable,
|
||||||
|
* channel-homed record (a task, a project card, …). User-signed and **regular**, not
|
||||||
|
* addressable — identity is `(community, d)` and the relay advances a per-identity head
|
||||||
|
* atomically, so every non-create revision must name the current head in `prev`
|
||||||
|
* (compare-and-swap; timestamps never pick a winner, see [ArtifactHeadResolver]).
|
||||||
|
*
|
||||||
|
* Envelope tags, each exactly once with two elements: `ar`=`1`, `d` (lowercase UUID), `h`
|
||||||
|
* (home channel UUID), `type` (namespaced, immutable), `title` (absent on delete), `op`,
|
||||||
|
* optional `root` (conversation anchor), and `prev` (required iff `op != create`). Any other
|
||||||
|
* tag is a client-defined, relay-matchable annotation ([clientTags]); `content` is opaque
|
||||||
|
* and empty on delete. At most 256 tags.
|
||||||
|
*
|
||||||
|
* Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs` (mirrored by
|
||||||
|
* [ArtifactValidator]), `accept_artifact` in `buzz-db/src/store/artifact.rs`, and
|
||||||
|
* `docs/nips/NIP-AR.md`. Buzz ships no SDK builder for this kind yet; [build] enforces the
|
||||||
|
* same envelope the relay validates.
|
||||||
|
*
|
||||||
|
* Reads: a plain REQ `{"kinds":[45010,45011],"#h":[channel]}` replays a channel's revisions
|
||||||
|
* and removals. Current-state and history views (and any multi-letter tag predicate such as
|
||||||
|
* `#project`) go through the relay's explicit artifact query (`{"artifact":"current"|"history",
|
||||||
|
* …}` on the NIP-98 HTTP `/query`); the relay rejects a REQ that mixes artifact kinds with
|
||||||
|
* multi-letter tag filters rather than silently dropping the predicate.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class ArtifactEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The artifact's stable UUID — the `d` tag. */
|
||||||
|
fun artifactId() = tags.artifactId()
|
||||||
|
|
||||||
|
/** The home channel UUID — the `h` tag. */
|
||||||
|
fun home() = tags.artifactHome()
|
||||||
|
|
||||||
|
/** The namespaced content type — the `type` tag. */
|
||||||
|
fun type() = tags.artifactType()
|
||||||
|
|
||||||
|
/** The display title — the `title` tag; null on a delete. */
|
||||||
|
fun title() = tags.artifactTitle()
|
||||||
|
|
||||||
|
/** The lifecycle operation — the `op` tag. */
|
||||||
|
fun op() = tags.artifactOp()
|
||||||
|
|
||||||
|
/** The conversation anchor — the `root` tag. */
|
||||||
|
fun root() = tags.artifactRoot()
|
||||||
|
|
||||||
|
/** The replaced revision — the `prev` tag; null on a create. */
|
||||||
|
fun prev() = tags.artifactPrev()
|
||||||
|
|
||||||
|
/** True for a soft-delete revision. */
|
||||||
|
fun isDelete() = op() == ArtifactOp.DELETE
|
||||||
|
|
||||||
|
/** The client-defined annotation tags an editor must carry forward. */
|
||||||
|
fun clientTags() = tags.artifactClientTags()
|
||||||
|
|
||||||
|
/** Runs the relay's envelope validation over this revision. */
|
||||||
|
fun validate(): ArtifactValidation = ArtifactValidator.validate(tags, content)
|
||||||
|
|
||||||
|
/** The validated envelope, or null when the relay would reject this revision. */
|
||||||
|
fun envelopeOrNull(): ArtifactEnvelope? = (validate() as? ArtifactValidation.Valid)?.envelope
|
||||||
|
|
||||||
|
fun isWellFormed() = validate() is ArtifactValidation.Valid
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 45010
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a revision and checks it against [ArtifactValidator] — including any client
|
||||||
|
* tags the [initializer] adds — so a template that the relay would reject never
|
||||||
|
* leaves this function.
|
||||||
|
*
|
||||||
|
* [title] must be null exactly when [op] is [ArtifactOp.DELETE] (and [content] then
|
||||||
|
* empty); [prev] must be null exactly when [op] is [ArtifactOp.CREATE].
|
||||||
|
*/
|
||||||
|
fun build(
|
||||||
|
artifactId: String,
|
||||||
|
channelId: String,
|
||||||
|
type: String,
|
||||||
|
op: ArtifactOp,
|
||||||
|
title: String?,
|
||||||
|
content: String,
|
||||||
|
prev: HexKey? = null,
|
||||||
|
root: HexKey? = null,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
|
||||||
|
): EventTemplate<ArtifactEvent> {
|
||||||
|
require(ArtifactIds.isCanonicalUuid(artifactId)) { "artifact id must be a lowercase, non-nil UUID" }
|
||||||
|
require(ArtifactIds.isCanonicalUuid(channelId)) { "channel id must be a lowercase, non-nil UUID" }
|
||||||
|
require(TypeTag.isValid(type)) { "invalid namespaced artifact type: $type" }
|
||||||
|
if (op == ArtifactOp.DELETE) {
|
||||||
|
require(title == null) { "a delete must omit the title" }
|
||||||
|
require(content.isEmpty()) { "a delete must have empty content" }
|
||||||
|
} else {
|
||||||
|
require(title != null && TitleTag.isValid(title)) { "title must be nonblank and at most ${TitleTag.MAX_BYTES} UTF-8 bytes" }
|
||||||
|
}
|
||||||
|
require((op == ArtifactOp.CREATE) == (prev == null)) { "prev is required exactly on non-create revisions" }
|
||||||
|
require(prev == null || ArtifactIds.isEventId(prev)) { "prev must be a 64-char lowercase hex event id" }
|
||||||
|
require(root == null || ArtifactIds.isEventId(root)) { "root must be a 64-char lowercase hex event id" }
|
||||||
|
|
||||||
|
val template =
|
||||||
|
eventTemplate<ArtifactEvent>(KIND, content, createdAt) {
|
||||||
|
artifactVersion()
|
||||||
|
artifactId(artifactId)
|
||||||
|
artifactHome(channelId)
|
||||||
|
artifactType(type)
|
||||||
|
title?.let { artifactTitle(it) }
|
||||||
|
artifactOp(op)
|
||||||
|
root?.let { artifactRoot(it) }
|
||||||
|
prev?.let { artifactPrev(it) }
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = ArtifactValidator.validate(template.tags, template.content)
|
||||||
|
require(result is ArtifactValidation.Valid) { "invalid artifact envelope: ${(result as ArtifactValidation.Invalid).reason}" }
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a new artifact identity. Pass client tags (e.g. `assignee`) via [initializer]. */
|
||||||
|
fun create(
|
||||||
|
artifactId: String,
|
||||||
|
channelId: String,
|
||||||
|
type: String,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
root: HexKey? = null,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
|
||||||
|
) = build(artifactId, channelId, type, ArtifactOp.CREATE, title, content, null, root, createdAt, initializer)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `update` on top of [current] (which must be the head the relay holds): keeps its
|
||||||
|
* `d`, `h`, `type` and `root`, names it in `prev`, and carries its client tags forward
|
||||||
|
* unless [keepClientTags] is false — NIP-AR editors must preserve annotations they do
|
||||||
|
* not understand. The [initializer] runs after the carry-over, so it can replace them.
|
||||||
|
*/
|
||||||
|
fun update(
|
||||||
|
current: ArtifactEvent,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
keepClientTags: Boolean = true,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
|
||||||
|
): EventTemplate<ArtifactEvent> {
|
||||||
|
val envelope = requireNotNull(current.envelopeOrNull()) { "current revision has an invalid envelope" }
|
||||||
|
return build(envelope.id, envelope.home, envelope.type, ArtifactOp.UPDATE, title, content, current.id, envelope.root, createdAt) {
|
||||||
|
if (keepClientTags) current.clientTags().forEach { add(it) }
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The soft delete of [current]: same `d`, `h`, `type` and `root` (the relay rejects a
|
||||||
|
* delete that changes the anchor), empty content, no title and no client tags.
|
||||||
|
*/
|
||||||
|
fun delete(
|
||||||
|
current: ArtifactEvent,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
): EventTemplate<ArtifactEvent> {
|
||||||
|
val envelope = requireNotNull(current.envelopeOrNull()) { "current revision has an invalid envelope" }
|
||||||
|
return build(envelope.id, envelope.home, envelope.type, ArtifactOp.DELETE, null, "", current.id, envelope.root, createdAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
|
||||||
|
/** What a set of revisions says about one artifact's current state. See [ArtifactHeadResolver]. */
|
||||||
|
@Immutable
|
||||||
|
sealed interface ArtifactHead {
|
||||||
|
/** No well-formed revision of the artifact was supplied. */
|
||||||
|
data object Unknown : ArtifactHead
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [revision] is the current head. [chainComplete] is true when its `prev` chain reaches the
|
||||||
|
* `create` with no missing revision; false when the head was found across a gap (a redacted,
|
||||||
|
* expired or unreadable revision), which is still unambiguous for a linear history.
|
||||||
|
*/
|
||||||
|
data class Current(
|
||||||
|
val revision: ArtifactEvent,
|
||||||
|
val chainComplete: Boolean,
|
||||||
|
) : ArtifactHead {
|
||||||
|
/** A soft-deleted artifact: hidden from active views until a `restore`. */
|
||||||
|
val isDeleted get() = revision.op() == ArtifactOp.DELETE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest visible revision, [lastRevision], was replaced by a move into a channel this
|
||||||
|
* reader cannot see — the relay-signed [removal] names it as `prev`.
|
||||||
|
*/
|
||||||
|
data class MovedAway(
|
||||||
|
val lastRevision: ArtifactEvent,
|
||||||
|
val removal: ArtifactRemovalEvent,
|
||||||
|
) : ArtifactHead
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The revisions do not determine a single head: a fork (impossible on a Buzz relay, which
|
||||||
|
* serializes every identity, so it signals revisions from elsewhere), several gaps, or a
|
||||||
|
* `prev` cycle ([candidates] is then empty). Ask the relay's current-state query
|
||||||
|
* (`{"artifact":"current","#d":[id]}`) instead of guessing.
|
||||||
|
*/
|
||||||
|
data class Ambiguous(
|
||||||
|
val candidates: List<ArtifactEvent>,
|
||||||
|
) : ArtifactHead
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an artifact's current head from its revisions the way a Buzz relay defines it: the
|
||||||
|
* latest *accepted* revision, where acceptance is a compare-and-swap on `prev` — every
|
||||||
|
* revision after the `create` names the head it replaced, so the accepted revisions form one
|
||||||
|
* linear chain. **Timestamps never choose the winner** (NIP-AR, "Identity and edits"), so
|
||||||
|
* neither does this resolver.
|
||||||
|
*
|
||||||
|
* The head is the one revision no other revision names as `prev` (a *tip*). Readers can see
|
||||||
|
* gaps — a NIP-29 `9005` redaction withholds a revision but keeps it accepted, retention
|
||||||
|
* expires old ones, and a moved artifact's earlier revisions stay under the source channel's
|
||||||
|
* access rules — which leaves more than one tip. Because the accepted history is linear, the
|
||||||
|
* segment that starts at the `create` is always the oldest, so when some other segment starts
|
||||||
|
* after a gap the create-rooted tip cannot be the head and is discarded. Anything still
|
||||||
|
* unresolved is reported as [ArtifactHead.Ambiguous] rather than picked by `created_at`.
|
||||||
|
*
|
||||||
|
* Ground truth: `accept_artifact` in Buzz's `buzz-db/src/store/artifact.rs` (the CAS),
|
||||||
|
* `removal_marker` in the same file, and `docs/nips/NIP-AR.md`.
|
||||||
|
*/
|
||||||
|
object ArtifactHeadResolver {
|
||||||
|
fun resolve(
|
||||||
|
artifactId: String,
|
||||||
|
revisions: Collection<ArtifactEvent>,
|
||||||
|
removals: Collection<ArtifactRemovalEvent> = emptyList(),
|
||||||
|
): ArtifactHead {
|
||||||
|
val byId = LinkedHashMap<HexKey, ArtifactEvent>()
|
||||||
|
val prevOf = HashMap<HexKey, HexKey?>()
|
||||||
|
val opOf = HashMap<HexKey, ArtifactOp>()
|
||||||
|
for (revision in revisions) {
|
||||||
|
if (revision.id in byId) continue
|
||||||
|
val envelope = revision.envelopeOrNull() ?: continue
|
||||||
|
if (envelope.id != artifactId) continue
|
||||||
|
byId[revision.id] = revision
|
||||||
|
prevOf[revision.id] = envelope.prev
|
||||||
|
opOf[revision.id] = envelope.op
|
||||||
|
}
|
||||||
|
if (byId.isEmpty()) return ArtifactHead.Unknown
|
||||||
|
|
||||||
|
val replaced = HashSet<HexKey>()
|
||||||
|
prevOf.values.forEach { if (it != null) replaced.add(it) }
|
||||||
|
|
||||||
|
val tips = byId.values.filter { it.id !in replaced }
|
||||||
|
if (tips.isEmpty()) return ArtifactHead.Ambiguous(emptyList())
|
||||||
|
|
||||||
|
// Walk each tip back to the oldest revision reachable from it.
|
||||||
|
val reachesCreate = tips.associateWith { tip -> opOf[segmentStart(tip.id, prevOf)] == ArtifactOp.CREATE }
|
||||||
|
|
||||||
|
val head: ArtifactEvent
|
||||||
|
val complete: Boolean
|
||||||
|
if (tips.size == 1) {
|
||||||
|
head = tips[0]
|
||||||
|
complete = reachesCreate.getValue(head)
|
||||||
|
} else {
|
||||||
|
val afterGap = tips.filter { !reachesCreate.getValue(it) }
|
||||||
|
if (afterGap.size != 1) return ArtifactHead.Ambiguous(tips)
|
||||||
|
head = afterGap[0]
|
||||||
|
complete = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val removal = removals.firstOrNull { it.artifactId() == artifactId && it.replacedRevision() == head.id }
|
||||||
|
if (removal != null) return ArtifactHead.MovedAway(head, removal)
|
||||||
|
|
||||||
|
return ArtifactHead.Current(head, complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The known revisions from [head] back along `prev`, newest first. Stops at the `create`,
|
||||||
|
* at the first revision not in [revisions], or on a cycle.
|
||||||
|
*/
|
||||||
|
fun history(
|
||||||
|
head: ArtifactEvent,
|
||||||
|
revisions: Collection<ArtifactEvent>,
|
||||||
|
): List<ArtifactEvent> {
|
||||||
|
val byId = revisions.associateBy { it.id }
|
||||||
|
val result = mutableListOf<ArtifactEvent>()
|
||||||
|
val seen = HashSet<HexKey>()
|
||||||
|
var current: ArtifactEvent? = head
|
||||||
|
while (current != null && seen.add(current.id)) {
|
||||||
|
result.add(current)
|
||||||
|
current = current.prev()?.let { byId[it] }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun segmentStart(
|
||||||
|
tip: HexKey,
|
||||||
|
prevOf: Map<HexKey, HexKey?>,
|
||||||
|
): HexKey {
|
||||||
|
val seen = HashSet<HexKey>()
|
||||||
|
var current = tip
|
||||||
|
while (seen.add(current)) {
|
||||||
|
val prev = prevOf[current] ?: return current
|
||||||
|
if (prev !in prevOf) return current
|
||||||
|
current = prev
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two identifier grammars NIP-AR's envelope uses, mirroring `canonical_uuid` and
|
||||||
|
* `event_id` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object ArtifactIds {
|
||||||
|
private val CANONICAL_UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||||
|
private const val NIL_UUID = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for a lowercase, hyphenated, non-nil UUID. Upstream parses with `Uuid::parse_str`
|
||||||
|
* and then requires `id.to_string() == value`, which only the lowercase hyphenated form
|
||||||
|
* satisfies (simple, braced, URN and uppercase forms all fail the round trip).
|
||||||
|
*/
|
||||||
|
fun isCanonicalUuid(value: String): Boolean = value.length == 36 && CANONICAL_UUID.matches(value) && value != NIL_UUID
|
||||||
|
|
||||||
|
/** True for a 64-character lowercase hex event id. */
|
||||||
|
fun isEventId(value: String): Boolean {
|
||||||
|
if (value.length != 64) return false
|
||||||
|
for (c in value) {
|
||||||
|
if (c !in '0'..'9' && c !in 'a'..'f') return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz NIP-AR artifact removal (`kind:45011`), **signed by the relay** and stored in the
|
||||||
|
* *source* channel when an artifact moves out of it, in the same transaction that accepts the
|
||||||
|
* `op=move` revision. It says only that artifact `d` left channel `h`, replacing revision
|
||||||
|
* `prev` — never the destination, title or content. Tags, in order: `["ar","1"]`,
|
||||||
|
* `["d",<artifact uuid>]`, `["h",<source channel uuid>]`, `["reason","moved"]`,
|
||||||
|
* `["prev",<replaced revision id>]`; `content` is empty.
|
||||||
|
*
|
||||||
|
* Clients never publish this kind; [build] exists for fixtures/tests. Ground truth:
|
||||||
|
* `removal_marker` in Buzz's `buzz-db/src/store/artifact.rs`.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class ArtifactRemovalEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The artifact that left the channel — the `d` tag. */
|
||||||
|
fun artifactId() = tags.artifactId()
|
||||||
|
|
||||||
|
/** The source channel the artifact was removed from — the `h` tag. */
|
||||||
|
fun sourceChannel() = tags.artifactHome()
|
||||||
|
|
||||||
|
/** Why it was removed — the `reason` tag (`moved`). */
|
||||||
|
fun reason() = tags.artifactRemovalReason()
|
||||||
|
|
||||||
|
/** The revision the move replaced (the last one readable in the source) — the `prev` tag. */
|
||||||
|
fun replacedRevision() = tags.artifactPrev()
|
||||||
|
|
||||||
|
/** True when the marker has the exact shape the relay emits. */
|
||||||
|
fun isWellFormed(): Boolean {
|
||||||
|
if (content.isNotEmpty()) return false
|
||||||
|
if (tags.artifactVersion() != VersionTag.CURRENT) return false
|
||||||
|
val id = artifactId() ?: return false
|
||||||
|
val source = sourceChannel() ?: return false
|
||||||
|
val prev = replacedRevision() ?: return false
|
||||||
|
return ArtifactIds.isCanonicalUuid(id) && ArtifactIds.isCanonicalUuid(source) && ArtifactIds.isEventId(prev) && reason() != null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 45011
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
artifactId: String,
|
||||||
|
sourceChannelId: String,
|
||||||
|
replacedRevision: HexKey,
|
||||||
|
reason: String = ReasonTag.MOVED,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ArtifactRemovalEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate<ArtifactRemovalEvent>(KIND, "", createdAt) {
|
||||||
|
artifactVersion()
|
||||||
|
artifactId(artifactId)
|
||||||
|
artifactHome(sourceChannelId)
|
||||||
|
artifactRemovalReason(reason)
|
||||||
|
artifactPrev(replacedRevision)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
|
||||||
|
/** The `ar` envelope-version tag. */
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactVersion(version: String = VersionTag.CURRENT) = addUnique(VersionTag.assemble(version))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artifact UUID as its `d` tag. Artifacts are regular (not addressable) events, so this
|
||||||
|
* is a plain `d` tag, not the NIP-01 addressable identifier.
|
||||||
|
*/
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactId(artifactId: String) = addUnique(DTag.assemble(artifactId))
|
||||||
|
|
||||||
|
/** The home (or, on a removal, source) channel — the `h` tag. */
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactHome(channelId: String) = addUnique(GroupIdTag.assemble(channelId))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactType(type: String) = addUnique(TypeTag.assemble(type))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactTitle(title: String) = addUnique(TitleTag.assemble(title))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactOp(op: ArtifactOp) = addUnique(OpTag.assemble(op))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactRoot(anchorEventId: HexKey) = addUnique(RootTag.assemble(anchorEventId))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactPrev(revisionId: HexKey) = addUnique(PrevTag.assemble(revisionId))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.artifactRemovalReason(reason: String = ReasonTag.MOVED) = addUnique(ReasonTag.assemble(reason))
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
|
||||||
|
/** The envelope version — the `ar` tag. */
|
||||||
|
fun TagArray.artifactVersion(): String? = fastFirstNotNullOfOrNull(VersionTag::parse)
|
||||||
|
|
||||||
|
/** The artifact's stable UUID — the `d` tag. */
|
||||||
|
fun TagArray.artifactId(): String? = firstTagValue(DTag.TAG_NAME)
|
||||||
|
|
||||||
|
/** The artifact's home channel UUID (the source channel on a removal) — the `h` tag. */
|
||||||
|
fun TagArray.artifactHome(): String? = fastFirstNotNullOfOrNull(GroupIdTag::parse)
|
||||||
|
|
||||||
|
/** The namespaced content type — the `type` tag. */
|
||||||
|
fun TagArray.artifactType(): String? = fastFirstNotNullOfOrNull(TypeTag::parse)
|
||||||
|
|
||||||
|
/** The display title — the `title` tag (absent on a delete). */
|
||||||
|
fun TagArray.artifactTitle(): String? = fastFirstNotNullOfOrNull(TitleTag::parse)
|
||||||
|
|
||||||
|
/** The lifecycle operation — the `op` tag. */
|
||||||
|
fun TagArray.artifactOp(): ArtifactOp? = fastFirstNotNullOfOrNull(OpTag::parse)
|
||||||
|
|
||||||
|
/** The conversation anchor — the `root` tag. */
|
||||||
|
fun TagArray.artifactRoot(): HexKey? = fastFirstNotNullOfOrNull(RootTag::parse)
|
||||||
|
|
||||||
|
/** The replaced revision — the `prev` tag. */
|
||||||
|
fun TagArray.artifactPrev(): HexKey? = fastFirstNotNullOfOrNull(PrevTag::parse)
|
||||||
|
|
||||||
|
/** Why a removal (`kind:45011`) was issued — the `reason` tag. */
|
||||||
|
fun TagArray.artifactRemovalReason(): String? = fastFirstNotNullOfOrNull(ReasonTag::parse)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client-defined tags: every tag that is neither envelope nor NIP-OA `auth`. NIP-AR
|
||||||
|
* editors MUST preserve these (and unfamiliar content fields) or decline the edit.
|
||||||
|
*/
|
||||||
|
fun TagArray.artifactClientTags(): List<Array<String>> = filter { it.isNotEmpty() && it[0] !in ArtifactValidator.ENVELOPE_TAG_NAMES && it[0] != ArtifactValidator.AUTH_TAG_NAME }
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lifecycle operation a NIP-AR revision performs, carried by the `op` tag ([OpTag]).
|
||||||
|
* Ground truth: `ArtifactOp` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
enum class ArtifactOp(
|
||||||
|
val code: String,
|
||||||
|
) {
|
||||||
|
/** First revision of a new identity; the only op without `prev`. */
|
||||||
|
CREATE("create"),
|
||||||
|
|
||||||
|
/** Content change within the same home channel. */
|
||||||
|
UPDATE("update"),
|
||||||
|
|
||||||
|
/** Content snapshot published into a new home channel. */
|
||||||
|
MOVE("move"),
|
||||||
|
|
||||||
|
/** Soft delete: empty content, no `title`; only [RESTORE] may follow. */
|
||||||
|
DELETE("delete"),
|
||||||
|
|
||||||
|
/** Complete snapshot that revives a deleted artifact. */
|
||||||
|
RESTORE("restore"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parse(code: String): ArtifactOp? =
|
||||||
|
when (code) {
|
||||||
|
CREATE.code -> CREATE
|
||||||
|
UPDATE.code -> UPDATE
|
||||||
|
MOVE.code -> MOVE
|
||||||
|
DELETE.code -> DELETE
|
||||||
|
RESTORE.code -> RESTORE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/** The NIP-AR `op` tag — the revision's [ArtifactOp]. */
|
||||||
|
object OpTag {
|
||||||
|
const val TAG_NAME = "op"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): ArtifactOp? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
return ArtifactOp.parse(tag[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(op: ArtifactOp) = arrayOf(TAG_NAME, op.code)
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `prev` tag — the id of the revision this one replaces. Required on every op
|
||||||
|
* except `create`, where it MUST be absent; the relay accepts a revision only when `prev`
|
||||||
|
* names the artifact's current head (compare-and-swap). The relay-signed removal
|
||||||
|
* (`kind:45011`) reuses it for the revision a move replaced. Ground truth: `validate` in
|
||||||
|
* `buzz-core/src/artifact.rs`, `accept_artifact` in `buzz-db/src/store/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object PrevTag {
|
||||||
|
const val TAG_NAME = "prev"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): HexKey? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(revisionId: HexKey) = arrayOf(TAG_NAME, revisionId)
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `reason` tag on a relay-signed NIP-AR removal (`kind:45011`). The relay only ever
|
||||||
|
* emits [MOVED]. Ground truth: `removal_marker` in Buzz's `buzz-db/src/store/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object ReasonTag {
|
||||||
|
const val TAG_NAME = "reason"
|
||||||
|
const val MOVED = "moved"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(reason: String = MOVED) = arrayOf(TAG_NAME, reason)
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `root` tag — an optional conversation anchor (a `kind:9`, `40002`, `45001` or
|
||||||
|
* `45003` event in the home channel) the artifact is attached to. When set on creation, or
|
||||||
|
* changed from the previous revision, the relay requires the anchor to exist, undeleted, in
|
||||||
|
* `h`; a delete must keep the current value. Ground truth: `accept_artifact` in
|
||||||
|
* `buzz-db/src/store/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object RootTag {
|
||||||
|
const val TAG_NAME = "root"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): HexKey? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(anchorEventId: HexKey) = arrayOf(TAG_NAME, anchorEventId)
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `title` tag — the artifact's display title. Required, nonblank and at most
|
||||||
|
* [MAX_BYTES] UTF-8 bytes on every revision except `op=delete`, where it MUST be absent.
|
||||||
|
* Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object TitleTag {
|
||||||
|
const val TAG_NAME = "title"
|
||||||
|
const val MAX_BYTES = 512
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(title: String) = arrayOf(TAG_NAME, title)
|
||||||
|
|
||||||
|
/** Nonblank and within [MAX_BYTES] UTF-8 bytes. */
|
||||||
|
fun isValid(title: String) = title.isNotBlank() && title.encodeToByteArray().size <= MAX_BYTES
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `type` tag — the artifact's immutable, namespaced content type (e.g.
|
||||||
|
* `buzz.task`, `buzz.project`). `buzz.*` is reserved for published Buzz client contracts.
|
||||||
|
* Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object TypeTag {
|
||||||
|
const val TAG_NAME = "type"
|
||||||
|
const val MAX_BYTES = 128
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(type: String) = arrayOf(TAG_NAME, type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type grammar: at most [MAX_BYTES] bytes, at least one `.`, and every dot-separated
|
||||||
|
* component non-empty, starting with `a-z`, and otherwise only `a-z`, `0-9`, `_` or `-`.
|
||||||
|
*/
|
||||||
|
fun isValid(type: String): Boolean {
|
||||||
|
if (type.length > MAX_BYTES || '.' !in type) return false
|
||||||
|
var componentStart = true
|
||||||
|
for (c in type) {
|
||||||
|
if (c == '.') {
|
||||||
|
if (componentStart) return false
|
||||||
|
componentStart = true
|
||||||
|
} else if (componentStart) {
|
||||||
|
if (c !in 'a'..'z') return false
|
||||||
|
componentStart = false
|
||||||
|
} else if (c !in 'a'..'z' && c !in '0'..'9' && c != '_' && c != '-') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !componentStart
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.arArtifacts.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-AR `ar` tag — the artifact envelope version. Only [CURRENT] (`"1"`) is defined;
|
||||||
|
* relays reject any other value ("unsupported artifact envelope version"). Carried by both
|
||||||
|
* the revision (`kind:45010`) and the relay-signed removal (`kind:45011`). Ground truth:
|
||||||
|
* `validate` in Buzz's `buzz-core/src/artifact.rs`.
|
||||||
|
*/
|
||||||
|
object VersionTag {
|
||||||
|
const val TAG_NAME = "ar"
|
||||||
|
const val CURRENT = "1"
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(version: String = CURRENT) = arrayOf(TAG_NAME, version)
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.cwChannelWindow
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON `content` of a Buzz NIP-CW thread-bounds overlay (`kind:39007`):
|
||||||
|
* `{"version":1,"direction":"older","has_more":<bool>,"next_cursor":{"created_at":<s>,"id":<hex>}|null}`.
|
||||||
|
* It is the sole authority on a thread window's exhaustion; clients echo [nextCursor] back as
|
||||||
|
* `until` + `before_id` to request the next (older) page. [isConsistent] carries the NIP's
|
||||||
|
* client-side checks on the body. Ground truth: `buzz-relay/src/api/bridge/thread_window.rs`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ThreadWindowBoundsContent(
|
||||||
|
val version: Int = VERSION,
|
||||||
|
val direction: String = DIRECTION_OLDER,
|
||||||
|
@SerialName("has_more") val hasMore: Boolean,
|
||||||
|
@SerialName("next_cursor") val nextCursor: NextCursor? = null,
|
||||||
|
) {
|
||||||
|
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||||
|
|
||||||
|
/** Version 1, direction `older`, and a cursor present exactly when [hasMore]. */
|
||||||
|
fun isConsistent() = version == VERSION && direction == DIRECTION_OLDER && (nextCursor != null) == hasMore
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VERSION = 1
|
||||||
|
const val DIRECTION_OLDER = "older"
|
||||||
|
|
||||||
|
// The relay writes `"next_cursor":null` on an exhausted page, so nulls stay explicit.
|
||||||
|
val JSON =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeFromJson(json: String): ThreadWindowBoundsContent = JSON.decodeFromString(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.cwChannelWindow
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz NIP-CW thread-bounds overlay (`kind:39007`), **signed by the relay** and synthesized
|
||||||
|
* per thread-mode query (never stored; the relay rejects it at ingest). Exactly one is
|
||||||
|
* appended to every served thread window — including empty and exhausted pages — and it is
|
||||||
|
* the only authority on the thread window's exhaustion. Deliberately distinct from the
|
||||||
|
* channel-mode [WindowBoundsEvent] (`39006`), whose channel/cursor key cannot tell concurrent
|
||||||
|
* roots apart.
|
||||||
|
*
|
||||||
|
* Tags are exactly `["d","tw:1:<sha256 hex>"]` (the request binding, see
|
||||||
|
* [ThreadWindowRequest.binding]), `["h",<channel uuid>]` and `["e",<root id>]`; `content` is a
|
||||||
|
* [ThreadWindowBoundsContent]. Before trusting it a client MUST check the relay signer and
|
||||||
|
* signature (the caller's job), the exact tags and binding ([matches]), and the body
|
||||||
|
* ([ThreadWindowBoundsContent.isConsistent]). Clients never publish this kind; [build] exists
|
||||||
|
* for fixtures/tests. Ground truth: `buzz-relay/src/api/bridge/thread_window.rs`,
|
||||||
|
* `buzz-core/src/thread_window.rs`, `docs/nips/NIP-CW.md` §Thread Bounds.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class ThreadWindowBoundsEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The request binding — the `d` tag, `tw:1:<sha256 hex>`. */
|
||||||
|
fun binding() = dTag()
|
||||||
|
|
||||||
|
/** The channel id — the `h` tag. */
|
||||||
|
fun channelId() = tags.firstNotNullOfOrNull(GroupIdTag::parse)
|
||||||
|
|
||||||
|
/** The thread root id — the `e` tag. */
|
||||||
|
fun rootId() = tags.firstNotNullOfOrNull(ETag::parseId)
|
||||||
|
|
||||||
|
/** Parses the JSON bounds [content]. Throws on malformed content; use [boundsOrNull]. */
|
||||||
|
fun bounds(): ThreadWindowBoundsContent = ThreadWindowBoundsContent.decodeFromJson(content)
|
||||||
|
|
||||||
|
fun boundsOrNull(): ThreadWindowBoundsContent? =
|
||||||
|
try {
|
||||||
|
bounds()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the tags are exactly one two-element `d`, `h` and `e` (and nothing else), the
|
||||||
|
* `d` is a `tw:1:` binding, the `h` a lowercase hyphenated UUID, the `e` a lowercase
|
||||||
|
* 64-hex id, and the body is consistent. Says nothing about which request it answers —
|
||||||
|
* use [matches] for that.
|
||||||
|
*/
|
||||||
|
fun isWellFormed(): Boolean {
|
||||||
|
if (tags.size != 3) return false
|
||||||
|
var d: String? = null
|
||||||
|
var h: String? = null
|
||||||
|
var e: String? = null
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.size != 2) return false
|
||||||
|
when (tag[0]) {
|
||||||
|
DTag.TAG_NAME -> if (d == null) d = tag[1] else return false
|
||||||
|
GroupIdTag.TAG_NAME -> if (h == null) h = tag[1] else return false
|
||||||
|
ETag.TAG_NAME -> if (e == null) e = tag[1] else return false
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d == null || h == null || e == null) return false
|
||||||
|
if (!d.startsWith(ThreadWindowRequest.BINDING_PREFIX) || !isLowercaseHex(d.substring(ThreadWindowRequest.BINDING_PREFIX.length), 64)) return false
|
||||||
|
if (!CANONICAL_UUID.matches(h) || !isLowercaseHex(e, 64)) return false
|
||||||
|
return boundsOrNull()?.isConsistent() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this well-formed overlay answers [request], as issued by [readerPubKey] to the
|
||||||
|
* relay at [host]: the binding, channel and root all match.
|
||||||
|
*/
|
||||||
|
fun matches(
|
||||||
|
request: ThreadWindowRequest,
|
||||||
|
host: String,
|
||||||
|
readerPubKey: HexKey,
|
||||||
|
): Boolean =
|
||||||
|
isWellFormed() &&
|
||||||
|
binding() == request.binding(host, readerPubKey) &&
|
||||||
|
channelId() == request.channelId.lowercase() &&
|
||||||
|
rootId() == request.rootId.lowercase()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 39007
|
||||||
|
|
||||||
|
private val CANONICAL_UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||||
|
|
||||||
|
private fun isLowercaseHex(
|
||||||
|
value: String,
|
||||||
|
length: Int,
|
||||||
|
) = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }
|
||||||
|
|
||||||
|
/** Builds the overlay the relay would sign for [request] (fixtures/tests only). */
|
||||||
|
fun build(
|
||||||
|
request: ThreadWindowRequest,
|
||||||
|
host: String,
|
||||||
|
readerPubKey: HexKey,
|
||||||
|
bounds: ThreadWindowBoundsContent,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ThreadWindowBoundsEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate<ThreadWindowBoundsEvent>(KIND, bounds.encodeToJson(), createdAt) {
|
||||||
|
dTag(request.binding(host, readerPubKey))
|
||||||
|
addUnique(GroupIdTag.assemble(request.channelId.lowercase()))
|
||||||
|
addUnique(ETag.assemble(request.rootId.lowercase(), null, null))
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.cwChannelWindow
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response-affecting arguments of a NIP-CW thread-mode request (`thread_window: true`,
|
||||||
|
* served on Buzz's NIP-98 HTTP `POST /query`), normalized the way the relay normalizes them,
|
||||||
|
* so a client can recompute the request binding a [ThreadWindowBoundsEvent] must carry.
|
||||||
|
*
|
||||||
|
* [kinds] must be drawn from `9`, `40002`, `45001`, `45003` (sorted and deduplicated here);
|
||||||
|
* [limit] is 1–200 (default 50), [depth] 1–100 (default 100); [cursor] is null for the head
|
||||||
|
* page, else the previous bounds' `next_cursor`. Ground truth: `Request::parse` and
|
||||||
|
* `Request::binding` in Buzz's `buzz-core/src/thread_window.rs`.
|
||||||
|
*/
|
||||||
|
data class ThreadWindowRequest(
|
||||||
|
val channelId: String,
|
||||||
|
val rootId: HexKey,
|
||||||
|
val kinds: List<Int>,
|
||||||
|
val limit: Int = DEFAULT_LIMIT,
|
||||||
|
val depth: Int = MAX_DEPTH,
|
||||||
|
val cursor: NextCursor? = null,
|
||||||
|
val includeAux: Boolean = false,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(kinds.isNotEmpty() && kinds.all { it in ROW_KINDS }) { "thread_window supports row kinds 9, 40002, 45001, 45003 only" }
|
||||||
|
require(limit in 1..MAX_LIMIT) { "thread_window: limit must be an integer in 1..=$MAX_LIMIT" }
|
||||||
|
require(depth in 1..MAX_DEPTH) { "thread_window: depth_limit must be an integer in 1..=$MAX_DEPTH" }
|
||||||
|
require(rootId.length == 64 && rootId.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { "thread_window: expected a full 64-hex event id" }
|
||||||
|
require(cursor == null || cursor.createdAt >= 0) { "thread_window: until must be nonnegative integer seconds" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `d` value of the bounds event answering this request for [readerPubKey] on [host]:
|
||||||
|
* `"tw:1:" + hex(SHA-256(compact JSON of
|
||||||
|
* ["tw",1,"older",host,reader,channel,root,limit,depth,kinds,cursor,include_aux]))`, where
|
||||||
|
* `cursor` is `null` or `[created_at,"<id>"]`. [host] is the relay's normalized authority
|
||||||
|
* (e.g. `relay.example` or `localhost:3000`); ids and the reader are lowercased first.
|
||||||
|
*/
|
||||||
|
fun binding(
|
||||||
|
host: String,
|
||||||
|
readerPubKey: HexKey,
|
||||||
|
): String {
|
||||||
|
val canonical =
|
||||||
|
JsonArray(
|
||||||
|
listOf(
|
||||||
|
JsonPrimitive("tw"),
|
||||||
|
JsonPrimitive(1),
|
||||||
|
JsonPrimitive(ThreadWindowBoundsContent.DIRECTION_OLDER),
|
||||||
|
JsonPrimitive(host),
|
||||||
|
JsonPrimitive(readerPubKey.lowercase()),
|
||||||
|
JsonPrimitive(channelId.lowercase()),
|
||||||
|
JsonPrimitive(rootId.lowercase()),
|
||||||
|
JsonPrimitive(limit),
|
||||||
|
JsonPrimitive(depth),
|
||||||
|
JsonArray(kinds.distinct().sorted().map { JsonPrimitive(it) }),
|
||||||
|
cursor?.let { JsonArray(listOf(JsonPrimitive(it.createdAt), JsonPrimitive(it.id.lowercase()))) } ?: JsonNull,
|
||||||
|
JsonPrimitive(includeAux),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return BINDING_PREFIX + sha256(canonical.toString().encodeToByteArray()).toHexKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val BINDING_PREFIX = "tw:1:"
|
||||||
|
const val DEFAULT_LIMIT = 50
|
||||||
|
const val MAX_LIMIT = 200
|
||||||
|
const val MAX_DEPTH = 100
|
||||||
|
|
||||||
|
/** Conversation row kinds a thread window may request. */
|
||||||
|
val ROW_KINDS = setOf(9, 40002, 45001, 45003)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -29,12 +29,19 @@ import kotlinx.serialization.json.Json
|
|||||||
/**
|
/**
|
||||||
* The `content` JSON shared by every Buzz huddle-lifecycle event (`kind:48100`–`48103`):
|
* The `content` JSON shared by every Buzz huddle-lifecycle event (`kind:48100`–`48103`):
|
||||||
* the id of the ephemeral audio channel the lifecycle transition applies to. The parent
|
* the id of the ephemeral audio channel the lifecycle transition applies to. The parent
|
||||||
* (timeline) channel is carried separately in the `h` tag. Field name is snake_case on
|
* (timeline) channel is carried separately in the `h` tag. Field names are snake_case on
|
||||||
* the wire. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
|
* the wire. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
|
||||||
|
*
|
||||||
|
* The relay also stamps the huddle session's [generation] (the same value the kind-48104
|
||||||
|
* liveness answer carries, so a client can tell a restarted session from the old one) and,
|
||||||
|
* when it has them, the participant roster's [rosterRevision] and the joiner's [admissionId].
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HuddleLifecycleContent(
|
data class HuddleLifecycleContent(
|
||||||
@SerialName("ephemeral_channel_id") val ephemeralChannelId: String,
|
@SerialName("ephemeral_channel_id") val ephemeralChannelId: String,
|
||||||
|
@SerialName("roster_revision") val rosterRevision: Long? = null,
|
||||||
|
@SerialName("admission_id") val admissionId: String? = null,
|
||||||
|
val generation: String? = null,
|
||||||
) {
|
) {
|
||||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||||
|
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.huddles
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `content` JSON of a relay-synthesized huddle-liveness snapshot (`kind:48104`):
|
||||||
|
* `{"ephemeral_channel_id": <session uuid>, "generation": <string>}`.
|
||||||
|
*
|
||||||
|
* [generation] is a **string** on the wire (the relay `to_string()`s it): the mesh lease
|
||||||
|
* generation (a monotonic decimal integer) when the relay runs a mesh, otherwise an opaque
|
||||||
|
* per-process epoch. Compare with [compareGenerations], never numerically on a `Long`.
|
||||||
|
* Ground truth: `handle_huddle_liveness_req` in Buzz's `buzz-relay/src/handlers/req.rs`;
|
||||||
|
* reader in `desktop/src/features/huddle/lib/huddlePresence.ts`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class HuddleLivenessContent(
|
||||||
|
@SerialName("ephemeral_channel_id") val ephemeralChannelId: String,
|
||||||
|
val generation: String,
|
||||||
|
) {
|
||||||
|
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val JSON =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeFromJson(json: String): HuddleLivenessContent = JSON.decodeFromString(json)
|
||||||
|
|
||||||
|
fun decodeFromJsonOrNull(json: String): HuddleLivenessContent? =
|
||||||
|
try {
|
||||||
|
decodeFromJson(json)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders two liveness generations the way Buzz's desktop does
|
||||||
|
* (`compareHuddleGenerations`): only when both are plain decimal integers (mesh
|
||||||
|
* generations, which are monotonic) is there an order, returned as -1/0/1 for
|
||||||
|
* [candidate] below/equal/above [current]. Anything else is an opaque epoch and yields
|
||||||
|
* null — a differing opaque epoch means "take a fresh snapshot", not "newer".
|
||||||
|
*/
|
||||||
|
fun compareGenerations(
|
||||||
|
candidate: String,
|
||||||
|
current: String,
|
||||||
|
): Int? {
|
||||||
|
if (!candidate.isDecimal() || !current.isDecimal()) return null
|
||||||
|
val a = candidate.trimStart('0')
|
||||||
|
val b = current.trimStart('0')
|
||||||
|
if (a.length != b.length) return if (a.length < b.length) -1 else 1
|
||||||
|
val cmp = a.compareTo(b)
|
||||||
|
return if (cmp < 0) {
|
||||||
|
-1
|
||||||
|
} else if (cmp > 0) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isDecimal() = isNotEmpty() && all { it in '0'..'9' }
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.huddles
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz huddle-liveness snapshot (`kind:48104`): the relay's authoritative statement that a
|
||||||
|
* huddle session is live right now, **synthesized and signed by the relay** per REQ and
|
||||||
|
* never stored. One event per live session: `["d",<session uuid>]` (the ephemeral audio
|
||||||
|
* channel), `["h",<parent channel uuid>]`, and a [HuddleLivenessContent] body. A session
|
||||||
|
* with no live room (or no mesh lease) simply gets no event, so absence from a snapshot is
|
||||||
|
* "not live".
|
||||||
|
*
|
||||||
|
* The relay only serves it to a REQ whose every filter has `kinds` exactly `[48104]` and
|
||||||
|
* that names at least one authorized `#h` channel — build it with [filter]. Clients never
|
||||||
|
* publish this kind; [build] exists for fixtures/tests. Ground truth:
|
||||||
|
* `filters_are_huddle_liveness_only`, `huddle_liveness_session_ids` and
|
||||||
|
* `handle_huddle_liveness_req` in Buzz's `buzz-relay/src/handlers/req.rs`.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class HuddleLivenessEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The live huddle session (its ephemeral audio channel UUID) — the `d` tag. */
|
||||||
|
fun sessionId(): String? = tags.firstTagValue(DTag.TAG_NAME)
|
||||||
|
|
||||||
|
/** The parent (timeline) channel UUID — the `h` tag. */
|
||||||
|
fun channelId(): String? = tags.huddleChannel()
|
||||||
|
|
||||||
|
/** The parsed body, or null when malformed (a malformed snapshot is not authoritative). */
|
||||||
|
fun liveness(): HuddleLivenessContent? = HuddleLivenessContent.decodeFromJsonOrNull(content)
|
||||||
|
|
||||||
|
/** The session's liveness generation, or null when missing/empty. */
|
||||||
|
fun generation(): String? = liveness()?.generation?.ifEmpty { null }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 48104
|
||||||
|
|
||||||
|
/** The relay truncates `#d` session ids (and bounds `#h` channels) to this many values. */
|
||||||
|
const val MAX_EXPLICIT_VALUES = 128
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The liveness REQ filter: `kinds` exactly `[48104]`, the parent channels as `#h`
|
||||||
|
* (required — the relay CLOSEs a liveness REQ without an authorized `#h`), and
|
||||||
|
* optionally the sessions of interest as `#d` with a matching `limit`, the shape
|
||||||
|
* Buzz's desktop sends. Without [sessionIds] the relay reports every live session it
|
||||||
|
* can link to those channels. Mixing any other kind into the filter turns it into an
|
||||||
|
* ordinary REQ that returns no liveness at all.
|
||||||
|
*/
|
||||||
|
fun filter(
|
||||||
|
channelIds: List<String>,
|
||||||
|
sessionIds: List<String>? = null,
|
||||||
|
): Filter {
|
||||||
|
require(channelIds.isNotEmpty()) { "huddle liveness requires at least one #h channel" }
|
||||||
|
require(channelIds.size <= MAX_EXPLICIT_VALUES) { "at most $MAX_EXPLICIT_VALUES channels per liveness REQ" }
|
||||||
|
require(sessionIds == null || sessionIds.size <= MAX_EXPLICIT_VALUES) { "at most $MAX_EXPLICIT_VALUES sessions per liveness REQ" }
|
||||||
|
|
||||||
|
val tags =
|
||||||
|
if (sessionIds.isNullOrEmpty()) {
|
||||||
|
mapOf(GroupIdTag.TAG_NAME to channelIds)
|
||||||
|
} else {
|
||||||
|
mapOf(GroupIdTag.TAG_NAME to channelIds, DTag.TAG_NAME to sessionIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Filter(
|
||||||
|
kinds = listOf(KIND),
|
||||||
|
tags = tags,
|
||||||
|
limit = sessionIds?.size?.takeIf { it > 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
sessionId: String,
|
||||||
|
channelId: String,
|
||||||
|
generation: String,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<HuddleLivenessEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate<HuddleLivenessEvent>(KIND, HuddleLivenessContent(sessionId, generation).encodeToJson(), createdAt) {
|
||||||
|
add(DTag.assemble(sessionId))
|
||||||
|
huddleChannel(channelId)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.identityNames
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One naming fact for an identity: its [pubkey], the [name] a client would show, whether it
|
||||||
|
* [isAgent], and the [ownerPubkey] an agent declares (a verified NIP-OA owner). Keys are 64-char hex,
|
||||||
|
* either case.
|
||||||
|
*/
|
||||||
|
data class NamingIdentity(
|
||||||
|
val pubkey: HexKey,
|
||||||
|
val name: String,
|
||||||
|
val isAgent: Boolean = false,
|
||||||
|
val ownerPubkey: HexKey? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A resolved display label. [qualifier] is the key suffix, when one was added. */
|
||||||
|
data class ResolvedIdentityName(
|
||||||
|
val name: String,
|
||||||
|
val qualifier: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buzz's "contextual identity names" v1: distinct display labels for the identities one view shows
|
||||||
|
* (a channel's members, a mention picker's choices), so two identities with the same name never look
|
||||||
|
* identical. A label only grows when a real collision exists in that context:
|
||||||
|
*
|
||||||
|
* 1. Within a collision, the highest-priority identity keeps its plain name: the viewer, then other
|
||||||
|
* people, then the viewer's own agents, then everyone else's agents.
|
||||||
|
* 2. A colliding agent first becomes readable: `Alice’s Honey` (its owner's name) when it is not the
|
||||||
|
* viewer's, else `Honey (agent)` when a person shares the name.
|
||||||
|
* 3. Whoever still collides gets a growing suffix of their npub: `Honey · 7xk2`.
|
||||||
|
*
|
||||||
|
* Display policy only; a label grants no authority, so keep the key for every action. Ported from
|
||||||
|
* Buzz's `mobile/lib/shared/identity_names/identity_name_policy.dart` and checked against its
|
||||||
|
* vendored portable fixtures (`IdentityNamePolicyTest`).
|
||||||
|
*/
|
||||||
|
object IdentityNamePolicy {
|
||||||
|
const val VERSION = 1
|
||||||
|
|
||||||
|
private val HEX_KEY = Regex("^[0-9a-fA-F]{64}$")
|
||||||
|
|
||||||
|
// ECMAScript String.prototype.trim code points, as the contract requires (U+0085 is kept).
|
||||||
|
private fun isContractWhitespace(c: Char): Boolean {
|
||||||
|
val u = c.code
|
||||||
|
return (u in 0x09..0x0D) ||
|
||||||
|
u == 0x20 ||
|
||||||
|
u == 0xA0 ||
|
||||||
|
u == 0x1680 ||
|
||||||
|
(u in 0x2000..0x200A) ||
|
||||||
|
u == 0x2028 ||
|
||||||
|
u == 0x2029 ||
|
||||||
|
u == 0x202F ||
|
||||||
|
u == 0x205F ||
|
||||||
|
u == 0x3000 ||
|
||||||
|
u == 0xFEFF
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trims [value] exactly as the contract specifies. */
|
||||||
|
fun trim(value: String): String {
|
||||||
|
var start = 0
|
||||||
|
var end = value.length
|
||||||
|
while (start < end && isContractWhitespace(value[start])) start++
|
||||||
|
while (end > start && isContractWhitespace(value[end - 1])) end--
|
||||||
|
return value.substring(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireKey(
|
||||||
|
key: String,
|
||||||
|
what: String,
|
||||||
|
) = require(HEX_KEY.matches(key)) { "$what is not a 64-character hex public key: $key" }
|
||||||
|
|
||||||
|
private class Row(
|
||||||
|
val key: String,
|
||||||
|
val identity: NamingIdentity,
|
||||||
|
val original: String,
|
||||||
|
val priority: Int,
|
||||||
|
val mine: Boolean,
|
||||||
|
) {
|
||||||
|
var base: String = original
|
||||||
|
var label: String = original
|
||||||
|
var length: Int = 0
|
||||||
|
var suffix: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves distinct labels for the [candidates] among [identities] (all of them when
|
||||||
|
* [candidates] is null; none for an empty set), as seen by [viewer]. Facts outside the
|
||||||
|
* candidates still serve as owner-name lookups. Throws on any invalid key.
|
||||||
|
*/
|
||||||
|
fun resolve(
|
||||||
|
identities: List<NamingIdentity>,
|
||||||
|
viewer: HexKey? = null,
|
||||||
|
candidates: Iterable<HexKey>? = null,
|
||||||
|
): Map<HexKey, ResolvedIdentityName> {
|
||||||
|
identities.forEach { identity ->
|
||||||
|
requireKey(identity.pubkey, "pubkey")
|
||||||
|
identity.ownerPubkey?.let { requireKey(it, "ownerPubkey") }
|
||||||
|
}
|
||||||
|
viewer?.let { requireKey(it, "viewer") }
|
||||||
|
val selected =
|
||||||
|
candidates
|
||||||
|
?.map {
|
||||||
|
requireKey(it, "candidates")
|
||||||
|
it.lowercase()
|
||||||
|
}?.toSet()
|
||||||
|
val normalizedViewer = viewer?.lowercase()
|
||||||
|
|
||||||
|
// Last fact per key: the preferred alias and the owner-lookup name.
|
||||||
|
val preferred = LinkedHashMap<String, NamingIdentity>()
|
||||||
|
identities.forEach { preferred[it.pubkey.lowercase()] = it }
|
||||||
|
|
||||||
|
// One working row per (key, trimmed name); the last fact supplies metadata.
|
||||||
|
val aliases = LinkedHashMap<Pair<String, String>, NamingIdentity>()
|
||||||
|
identities.forEach { aliases[it.pubkey.lowercase() to trim(it.name)] = it }
|
||||||
|
|
||||||
|
val rows =
|
||||||
|
aliases.mapNotNull { (keyAndName, identity) ->
|
||||||
|
val (key, name) = keyAndName
|
||||||
|
if (selected != null && key !in selected) return@mapNotNull null
|
||||||
|
val owner = identity.ownerPubkey?.lowercase()
|
||||||
|
val mine = normalizedViewer != null && (key == normalizedViewer || owner == normalizedViewer)
|
||||||
|
Row(
|
||||||
|
key = key,
|
||||||
|
identity = identity,
|
||||||
|
original = name,
|
||||||
|
mine = mine,
|
||||||
|
priority =
|
||||||
|
if (identity.isAgent) {
|
||||||
|
if (mine) 2 else 3
|
||||||
|
} else {
|
||||||
|
if (key == normalizedViewer) 0 else 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val npubs = HashMap<String, String>()
|
||||||
|
|
||||||
|
fun npubFor(key: String) = npubs.getOrPut(key) { NPub.create(key) }
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val groups = LinkedHashMap<String, MutableList<Row>>()
|
||||||
|
rows.forEach { groups.getOrPut(it.label) { mutableListOf() }.add(it) }
|
||||||
|
val collisions = groups.values.filter { group -> group.map { it.key }.toSet().size > 1 }
|
||||||
|
if (collisions.isEmpty()) break
|
||||||
|
|
||||||
|
for (group in collisions) {
|
||||||
|
val best = group.minOf { it.priority }
|
||||||
|
val winners = group.filter { it.priority == best }.map { it.key }.toSet()
|
||||||
|
val changing = group.filter { winners.size != 1 || it.key !in winners }
|
||||||
|
val groupHasHuman = group.any { !it.identity.isAgent }
|
||||||
|
|
||||||
|
var qualified = false
|
||||||
|
for (row in changing) {
|
||||||
|
if (!row.identity.isAgent || row.length != 0) continue
|
||||||
|
val ownerFact =
|
||||||
|
row.identity.ownerPubkey
|
||||||
|
?.lowercase()
|
||||||
|
?.let { preferred[it] }
|
||||||
|
val owner = ownerFact?.let { trim(it.name) } ?: ""
|
||||||
|
val readable =
|
||||||
|
when {
|
||||||
|
!row.mine && owner.isNotEmpty() -> "$owner’s ${row.original}"
|
||||||
|
groupHasHuman -> "${row.original} (agent)"
|
||||||
|
else -> row.base
|
||||||
|
}
|
||||||
|
if (readable != row.base) {
|
||||||
|
row.base = readable
|
||||||
|
row.label = readable
|
||||||
|
qualified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (qualified) continue
|
||||||
|
|
||||||
|
for (row in changing) {
|
||||||
|
row.length = if (row.length == 0) 4 else row.length + 1
|
||||||
|
val npub = npubFor(row.key)
|
||||||
|
val suffix = if (row.length <= npub.length) npub.substring(npub.length - row.length) else "$npub · ${row.length - npub.length}"
|
||||||
|
row.suffix = suffix
|
||||||
|
row.label = "${row.base} · $suffix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = LinkedHashMap<HexKey, ResolvedIdentityName>()
|
||||||
|
rows.forEach { row ->
|
||||||
|
if (row.original == trim(preferred.getValue(row.key).name)) {
|
||||||
|
result[row.key] = ResolvedIdentityName(row.label, row.suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-9
@@ -31,16 +31,19 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
*
|
*
|
||||||
* - `<payloadB64url>.<sigB64url>` (base64url, JWT-style but not a JWT) — the payload names the
|
* - `<payloadB64url>.<sigB64url>` (base64url, JWT-style but not a JWT) — the payload names the
|
||||||
* community, the granted role, an expiry and a nonce.
|
* community, the granted role, an expiry and a nonce.
|
||||||
* - `v2.<opaqueB64url>` — a bare server-side handle. Nothing about the invite is readable here;
|
* - `v2.<opaqueB64url>` — a bare server-side handle: `v2.` plus the unpadded, canonical base64url
|
||||||
* the relay resolves it on claim.
|
* encoding of a 32-byte secret (exactly what `validate_v2_code` in `buzz-core/src/invite.rs`
|
||||||
|
* accepts — see [BuzzInviteLink.isValidV2Code]). Nothing about the invite is readable here; the
|
||||||
|
* relay resolves it on claim.
|
||||||
*
|
*
|
||||||
* A Buzz invite is **not** a NIP-29 invite code (kind 9009) — it is redeemed over HTTP against
|
* A Buzz invite is **not** a NIP-29 invite code (kind 9009) — it is redeemed over HTTP against
|
||||||
* the relay's tenant host: `POST /api/invites/claim`, NIP-98-signed by the joining key, after
|
* the relay's tenant host: `POST /api/invites/claim`, NIP-98-signed by the joining key, after
|
||||||
* accepting any configured join policy. The relay verifies the token's MAC (its own key), so a
|
* accepting any configured join policy. The relay verifies the token's MAC (its own key), so a
|
||||||
* client only needs to read the payload, never validate the signature.
|
* client only needs to read the payload, never validate the signature.
|
||||||
*
|
*
|
||||||
* Ground truth: `buzz-relay/src/invite_token.rs` (token shape + `verify_invite`) and
|
* Ground truth: `buzz-relay/src/invite_token.rs` (token shape + `verify_invite`),
|
||||||
* `buzz-relay/src/api/invites.rs` (`claim_invite`, `accept_policy`).
|
* `buzz-core/src/invite.rs` (`validate_v2_code`) and `buzz-relay/src/api/invites.rs`
|
||||||
|
* (`claim_invite`, `accept_policy`).
|
||||||
*/
|
*/
|
||||||
data class BuzzInvite(
|
data class BuzzInvite(
|
||||||
/** The tenant host the invite is scoped to, e.g. `team.communities.buzz.xyz`. */
|
/** The tenant host the invite is scoped to, e.g. `team.communities.buzz.xyz`. */
|
||||||
@@ -74,6 +77,15 @@ object BuzzInviteLink {
|
|||||||
/** The payload segment of an opaque, server-resolved token. */
|
/** The payload segment of an opaque, server-resolved token. */
|
||||||
private const val V2_PREFIX = "v2"
|
private const val V2_PREFIX = "v2"
|
||||||
|
|
||||||
|
/** The full prefix the relay routes on (`V2_PREFIX` in `buzz-core/src/invite.rs`). */
|
||||||
|
const val V2_CODE_PREFIX = "v2."
|
||||||
|
|
||||||
|
/** Random bytes behind a `v2.` code (`V2_SECRET_LEN` in `buzz-core/src/invite.rs`). */
|
||||||
|
const val V2_SECRET_LEN = 32
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
private val B64_NO_PAD = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
|
||||||
|
|
||||||
/** What the relay grants when the token doesn't say — and it never says for `v2.`. */
|
/** What the relay grants when the token doesn't say — and it never says for `v2.`. */
|
||||||
private const val DEFAULT_ROLE = "member"
|
private const val DEFAULT_ROLE = "member"
|
||||||
|
|
||||||
@@ -115,12 +127,13 @@ object BuzzInviteLink {
|
|||||||
if (payloadB64 == code || payloadB64.isEmpty()) return null
|
if (payloadB64 == code || payloadB64.isEmpty()) return null
|
||||||
|
|
||||||
// `v2.<opaque>` carries no client-readable payload — the community, role and expiry live
|
// `v2.<opaque>` carries no client-readable payload — the community, role and expiry live
|
||||||
// only on the relay, which resolves the code on claim and returns them. There is nothing
|
// only on the relay, which resolves the code on claim and returns them. The join flow
|
||||||
// to decode and nothing to lose by admitting it: the join flow needs the host (for the
|
// needs the host (for the relay url and the REST base) and the code, both of which the url
|
||||||
// relay url and the REST base) and the code, both of which the url itself carries, and the
|
// itself carries, and the claim response supplies the rest. The relay routes on this exact
|
||||||
// claim response supplies the rest. Matched on the literal prefix rather than by relaxing
|
// prefix and never falls back to the v1 verifier, so a malformed `v2.` code is rejected
|
||||||
// the decode below, so `…/invite/anything.else` still fails to parse.
|
// here too rather than handed to a claim that can only answer `invite_invalid`.
|
||||||
if (payloadB64 == V2_PREFIX) {
|
if (payloadB64 == V2_PREFIX) {
|
||||||
|
if (!isValidV2Code(code)) return null
|
||||||
return BuzzInvite(host = host, code = code, communityId = "", role = DEFAULT_ROLE, expiresAt = null)
|
return BuzzInvite(host = host, code = code, communityId = "", role = DEFAULT_ROLE, expiresAt = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +155,24 @@ object BuzzInviteLink {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [code] is exactly `v2.` followed by the unpadded base64url encoding of a 32-byte
|
||||||
|
* secret, in its canonical form — mirroring `validate_v2_code` in `buzz-core/src/invite.rs`,
|
||||||
|
* whose decode/re-encode comparison rejects padded or otherwise aliased encodings.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun isValidV2Code(code: String): Boolean {
|
||||||
|
if (!code.startsWith(V2_CODE_PREFIX)) return false
|
||||||
|
val encoded = code.substring(V2_CODE_PREFIX.length)
|
||||||
|
val secret =
|
||||||
|
try {
|
||||||
|
B64_NO_PAD.decode(encoded)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return secret.size == V2_SECRET_LEN && B64_NO_PAD.encode(secret) == encoded
|
||||||
|
}
|
||||||
|
|
||||||
/** The host between the scheme's `//` and the `/invite/` marker, or null when absent. */
|
/** The host between the scheme's `//` and the `/invite/` marker, or null when absent. */
|
||||||
private fun extractHost(
|
private fun extractHost(
|
||||||
url: String,
|
url: String,
|
||||||
@@ -159,3 +190,49 @@ object BuzzInviteLink {
|
|||||||
return if (remainder == 0) s else s + "=".repeat(4 - remainder)
|
return if (remainder == 0) s else s + "=".repeat(4 - remainder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome vocabulary of Buzz's invite claim endpoint (`POST /api/invites/claim` in
|
||||||
|
* `buzz-relay/src/api/invites.rs`). A success answers 200 with a [STATUS_JOINED] or
|
||||||
|
* [STATUS_ALREADY_MEMBER] `status`; a refusal answers 403 with `{"error": <slug>}`.
|
||||||
|
*/
|
||||||
|
object BuzzInviteClaim {
|
||||||
|
/** 200: the claimer was added to the workspace. */
|
||||||
|
const val STATUS_JOINED = "joined"
|
||||||
|
|
||||||
|
/** 200: the claimer was already a member — nothing changed, and no use was consumed. */
|
||||||
|
const val STATUS_ALREADY_MEMBER = "already_member"
|
||||||
|
|
||||||
|
/** 403: the invite's lifetime has passed. */
|
||||||
|
const val ERROR_EXPIRED = "invite_expired"
|
||||||
|
|
||||||
|
/** 403: a `v2.` invite minted with `max_uses` has no uses left. */
|
||||||
|
const val ERROR_EXHAUSTED = "invite_exhausted"
|
||||||
|
|
||||||
|
/** 403: unknown, revoked, malformed, or minted for another workspace. */
|
||||||
|
const val ERROR_INVALID = "invite_invalid"
|
||||||
|
|
||||||
|
/** 403: the workspace has a join policy and no valid acceptance receipt was sent. */
|
||||||
|
const val ERROR_JOIN_POLICY_REQUIRED = "join_policy_required"
|
||||||
|
|
||||||
|
private val JSON = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class Body(
|
||||||
|
val status: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The `status` of a claim response body, or null when absent/unparseable. */
|
||||||
|
fun statusOf(body: String): String? = parse(body)?.status
|
||||||
|
|
||||||
|
/** The `error` slug of a claim response body, or null when absent/unparseable. */
|
||||||
|
fun errorOf(body: String): String? = parse(body)?.error
|
||||||
|
|
||||||
|
private fun parse(body: String): Body? =
|
||||||
|
try {
|
||||||
|
JSON.decodeFromString<Body>(body)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz NIP-MP project (`kind:30621`): an addressable, signed, named grouping of NIP-34
|
||||||
|
* repository announcements (`kind:30617`,
|
||||||
|
* [com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent]) referenced by
|
||||||
|
* coordinate, so one project can span repositories owned by different pubkeys. It is metadata
|
||||||
|
* only — its signer gains no authority over any member.
|
||||||
|
*
|
||||||
|
* Tags: exactly one non-empty `d` (the project slug), optional `name` (≤256 bytes; clients
|
||||||
|
* fall back to `d`), optional `description` (≤2048 bytes), 0–64 member `a` tags
|
||||||
|
* ([ProjectMember]; no two with the same coordinate), optional `buzz-channel` and
|
||||||
|
* `buzz-visibility` (`listed`|`unlisted`, anything else reads as listed). `content` is `""`
|
||||||
|
* and carries no meaning; unknown tags are ignored. Global-only: a stray `h` never scopes it.
|
||||||
|
*
|
||||||
|
* Ground truth: `build_project` / `validate_project_envelope` in Buzz's
|
||||||
|
* `buzz-sdk/src/builders.rs`, `validate_project_envelope` in
|
||||||
|
* `buzz-relay/src/handlers/ingest.rs` (mirrored by [ProjectValidator]), and
|
||||||
|
* `docs/nips/NIP-MP.md` with its `NIP-MP.fixtures.json` oracle.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class ProjectEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
/** The project slug — the `d` tag. */
|
||||||
|
fun slug() = dTag()
|
||||||
|
|
||||||
|
/** The `name` tag, if any. */
|
||||||
|
fun name() = tags.projectName()
|
||||||
|
|
||||||
|
/** What clients display: the `name`, falling back to the slug. */
|
||||||
|
fun displayName() = name() ?: slug()
|
||||||
|
|
||||||
|
fun description() = tags.projectDescription()
|
||||||
|
|
||||||
|
/** The well-formed member repositories. */
|
||||||
|
fun members(): List<ProjectMember> = tags.projectMembers()
|
||||||
|
|
||||||
|
/** The member repositories' `30617` addresses. */
|
||||||
|
fun memberAddresses(): List<Address> = members().map { it.address }
|
||||||
|
|
||||||
|
/** The discussion channel reference — metadata, not routing. */
|
||||||
|
fun channelId() = tags.projectChannel()
|
||||||
|
|
||||||
|
fun visibility(): ProjectVisibility = tags.projectVisibility()
|
||||||
|
|
||||||
|
fun isListed() = visibility() == ProjectVisibility.LISTED
|
||||||
|
|
||||||
|
/** The relay's ingest verdict: null when accepted, else the rule that rejects it. */
|
||||||
|
fun validate(): ProjectRejection? = ProjectValidator.validate(tags)
|
||||||
|
|
||||||
|
fun isWellFormed() = validate() == null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 30621
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a project with Buzz's writer policy (`build_project`): a non-empty slug of at
|
||||||
|
* most 1024 bytes, [channelId] a UUID, empty content, and the whole envelope checked
|
||||||
|
* by [ProjectValidator] (so duplicate or oversized members and metadata are refused
|
||||||
|
* here instead of by the relay).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
fun build(
|
||||||
|
slug: String,
|
||||||
|
name: String? = null,
|
||||||
|
description: String? = null,
|
||||||
|
members: List<ProjectMember> = emptyList(),
|
||||||
|
channelId: String? = null,
|
||||||
|
visibility: ProjectVisibility? = null,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ProjectEvent>.() -> Unit = {},
|
||||||
|
): EventTemplate<ProjectEvent> {
|
||||||
|
require(slug.isNotEmpty()) { "project slug must not be empty" }
|
||||||
|
require(slug.encodeToByteArray().size <= ProjectValidator.D_MAX_BYTES) { "project slug must not exceed ${ProjectValidator.D_MAX_BYTES} bytes" }
|
||||||
|
if (channelId != null) {
|
||||||
|
require(runCatching { Uuid.parse(channelId) }.isSuccess) { "buzz-channel must be a valid UUID (got $channelId)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val template =
|
||||||
|
eventTemplate<ProjectEvent>(KIND, "", createdAt) {
|
||||||
|
dTag(slug)
|
||||||
|
name?.let { projectName(it) }
|
||||||
|
description?.let { projectDescription(it) }
|
||||||
|
projectMembers(members)
|
||||||
|
channelId?.let { projectChannel(it) }
|
||||||
|
visibility?.let { projectVisibility(it) }
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectValidator.validate(template.tags)?.let { throw IllegalArgumentException("[${it.rule.id}] ${it.message}") }
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eight NIP-MP ingest rules. [id] is the stable identifier the shared conformance
|
||||||
|
* fixtures (`docs/nips/NIP-MP.fixtures.json` in Buzz) name in `reject_rules`.
|
||||||
|
*/
|
||||||
|
enum class ProjectRule(
|
||||||
|
val id: String,
|
||||||
|
) {
|
||||||
|
D_CARDINALITY("d-cardinality"),
|
||||||
|
D_EMPTY("d-empty"),
|
||||||
|
MEMBER_CAP("member-cap"),
|
||||||
|
MEMBER_TAG_ARITY("member-tag-arity"),
|
||||||
|
MEMBER_COORDINATE_MALFORMED("member-coordinate-malformed"),
|
||||||
|
MEMBER_DUPLICATE("member-duplicate"),
|
||||||
|
METADATA_CARDINALITY("metadata-cardinality"),
|
||||||
|
METADATA_LENGTH("metadata-length"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rejected project envelope: which [rule] fired and why. */
|
||||||
|
data class ProjectRejection(
|
||||||
|
val rule: ProjectRule,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-MP envelope validation — a port of `validate_project_envelope` in Buzz's
|
||||||
|
* `buzz-relay/src/handlers/ingest.rs` (and the matching Layer-A validator in
|
||||||
|
* `buzz-sdk/src/builders.rs`), evaluated in the relay's order so the first failing rule is the
|
||||||
|
* one the relay would report. `content` is never inspected and unknown tags are ignored.
|
||||||
|
* Deliberately absent, as upstream: any membership authorization — referencing another
|
||||||
|
* owner's repository is legal and grants nothing.
|
||||||
|
*/
|
||||||
|
object ProjectValidator {
|
||||||
|
/** Maximum member `a` tags, counted over raw tags (duplicates included). */
|
||||||
|
const val MEMBER_CAP = 64
|
||||||
|
|
||||||
|
/** The relay's generic `d` bound (`D_TAG_MAX_LEN`); the SDK reports it as `d-empty`. */
|
||||||
|
const val D_MAX_BYTES = 1024
|
||||||
|
const val NAME_MAX_BYTES = 256
|
||||||
|
const val DESCRIPTION_MAX_BYTES = 2048
|
||||||
|
const val METADATA_TAG_MAX_BYTES = 256
|
||||||
|
|
||||||
|
/** Metadata tags a project may carry at most once each, in the relay's check order. */
|
||||||
|
private val SINGLETON_METADATA =
|
||||||
|
listOf(
|
||||||
|
NameTag.TAG_NAME to NAME_MAX_BYTES,
|
||||||
|
DescriptionTag.TAG_NAME to DESCRIPTION_MAX_BYTES,
|
||||||
|
ChannelTag.TAG_NAME to ChannelTag.MAX_BYTES,
|
||||||
|
VisibilityTag.TAG_NAME to VisibilityTag.MAX_BYTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Null when [tags] form a valid project envelope; otherwise the first rule that fails. */
|
||||||
|
fun validate(tags: TagArray): ProjectRejection? {
|
||||||
|
val dValues = mutableListOf<String>()
|
||||||
|
val members = mutableListOf<Array<String>>()
|
||||||
|
val singletonCounts = IntArray(SINGLETON_METADATA.size)
|
||||||
|
val singletonValues = arrayOfNulls<String>(SINGLETON_METADATA.size)
|
||||||
|
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.isEmpty()) continue
|
||||||
|
val value = tag.getOrNull(1) ?: ""
|
||||||
|
when (val name = tag[0]) {
|
||||||
|
DTag.TAG_NAME -> dValues.add(value)
|
||||||
|
ProjectMemberTag.TAG_NAME -> members.add(tag)
|
||||||
|
else -> {
|
||||||
|
val index = SINGLETON_METADATA.indexOfFirst { it.first == name }
|
||||||
|
if (index >= 0) {
|
||||||
|
singletonCounts[index]++
|
||||||
|
singletonValues[index] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dValues.size != 1) return reject(ProjectRule.D_CARDINALITY, "project event must have exactly one `d` tag (got ${dValues.size})")
|
||||||
|
if (dValues[0].isEmpty()) return reject(ProjectRule.D_EMPTY, "project event `d` tag must not be empty")
|
||||||
|
if (dValues[0].utf8Size() > D_MAX_BYTES) return reject(ProjectRule.D_EMPTY, "project event `d` tag exceeds $D_MAX_BYTES bytes")
|
||||||
|
|
||||||
|
if (members.size > MEMBER_CAP) return reject(ProjectRule.MEMBER_CAP, "project event must have at most $MEMBER_CAP member `a` tags (got ${members.size})")
|
||||||
|
for (member in members) {
|
||||||
|
if (member.size !in 2..3) return reject(ProjectRule.MEMBER_TAG_ARITY, "project event member `a` tag must have exactly 2 or 3 elements (got ${member.size})")
|
||||||
|
}
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
for (member in members) {
|
||||||
|
val coordinate = member[1]
|
||||||
|
if (!ProjectMemberTag.isValidCoordinate(coordinate)) {
|
||||||
|
return reject(ProjectRule.MEMBER_COORDINATE_MALFORMED, "project event member `a` tag must be `30617:<lowercase-64-hex-owner>:<repo-d>` (got \"$coordinate\")")
|
||||||
|
}
|
||||||
|
if (!seen.add(coordinate)) return reject(ProjectRule.MEMBER_DUPLICATE, "project event has duplicate member coordinate \"$coordinate\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in SINGLETON_METADATA.indices) {
|
||||||
|
if (singletonCounts[i] > 1) {
|
||||||
|
return reject(ProjectRule.METADATA_CARDINALITY, "project event must have at most one `${SINGLETON_METADATA[i].first}` tag (got ${singletonCounts[i]})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i in SINGLETON_METADATA.indices) {
|
||||||
|
val value = singletonValues[i] ?: continue
|
||||||
|
val (name, max) = SINGLETON_METADATA[i]
|
||||||
|
if (value.utf8Size() > max) return reject(ProjectRule.METADATA_LENGTH, "project event `$name` tag too long (${value.utf8Size()} bytes, max $max)")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reject(
|
||||||
|
rule: ProjectRule,
|
||||||
|
message: String,
|
||||||
|
) = ProjectRejection(rule, message)
|
||||||
|
|
||||||
|
private fun String.utf8Size() = encodeToByteArray().size
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag
|
||||||
|
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectName(name: String) = addUnique(NameTag.assemble(name))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectDescription(description: String) = addUnique(DescriptionTag.assemble(description))
|
||||||
|
|
||||||
|
/** One `a` tag per member. Duplicate coordinates are not merged — the relay rejects them. */
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectMembers(members: List<ProjectMember>) = members.forEach { add(ProjectMemberTag.assemble(it)) }
|
||||||
|
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectChannel(channelId: String) = addUnique(ChannelTag.assemble(channelId))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<ProjectEvent>.projectVisibility(visibility: ProjectVisibility) = addUnique(VisibilityTag.assemble(visibility))
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility
|
||||||
|
import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag
|
||||||
|
|
||||||
|
/** The display name — the `name` tag. */
|
||||||
|
fun TagArray.projectName(): String? = fastFirstNotNullOfOrNull(NameTag::parse)
|
||||||
|
|
||||||
|
/** The description — the `description` tag. */
|
||||||
|
fun TagArray.projectDescription(): String? = fastFirstNotNullOfOrNull(DescriptionTag::parse)
|
||||||
|
|
||||||
|
/** The member repositories whose `a` tags are well-formed (malformed ones are skipped). */
|
||||||
|
fun TagArray.projectMembers(): List<ProjectMember> = fastMapNotNullDense(ProjectMemberTag::parse)
|
||||||
|
|
||||||
|
/** The discussion channel reference — the `buzz-channel` tag. */
|
||||||
|
fun TagArray.projectChannel(): String? = fastFirstNotNullOfOrNull(ChannelTag::parse)
|
||||||
|
|
||||||
|
/** The raw `buzz-visibility` value, uninterpreted. */
|
||||||
|
fun TagArray.projectVisibilityCode(): String? = fastFirstNotNullOfOrNull(VisibilityTag::parse)
|
||||||
|
|
||||||
|
/** The interpreted visibility: [ProjectVisibility.UNLISTED] only for exactly `unlisted`. */
|
||||||
|
fun TagArray.projectVisibility(): ProjectVisibility = ProjectVisibility.interpret(projectVisibilityCode())
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-MP `buzz-channel` tag — the UUID of the channel a project's discussion lives in.
|
||||||
|
* Metadata only: it neither routes nor scopes the event (projects are global), and a value
|
||||||
|
* the viewer cannot resolve must render as "no channel link", never as a broken one. The
|
||||||
|
* relay bounds it to [MAX_BYTES] and does not interpret it; Buzz's writer requires a UUID.
|
||||||
|
* Ground truth: `build_project` in Buzz's `buzz-sdk/src/builders.rs`.
|
||||||
|
*/
|
||||||
|
object ChannelTag {
|
||||||
|
const val TAG_NAME = "buzz-channel"
|
||||||
|
const val MAX_BYTES = 256
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(channelId: String) = arrayOf(TAG_NAME, channelId)
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects.tags
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.arrayOfNotNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One member repository of a NIP-MP project: the NIP-34 repository announcement's
|
||||||
|
* [address] (`30617:<owner>:<repo-d>`) and an optional, opaque, unauthenticated
|
||||||
|
* [relayHint]. Member identity is the coordinate alone — two tags naming one coordinate with
|
||||||
|
* different hints are duplicates.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ProjectMember(
|
||||||
|
val address: Address,
|
||||||
|
val relayHint: String? = null,
|
||||||
|
) {
|
||||||
|
/** The canonical coordinate string, the identity members are compared by. */
|
||||||
|
val coordinate: String get() = address.toValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A NIP-MP member `a` tag: `["a", "30617:<owner>:<repo-d>"]` or
|
||||||
|
* `["a", "30617:<owner>:<repo-d>", "<relay-url>"]`.
|
||||||
|
*
|
||||||
|
* The coordinate grammar is stricter than a generic NIP-01 address: the kind is the literal
|
||||||
|
* `30617` (a repository *announcement*, never `30618` state), the owner is exactly 64
|
||||||
|
* **lowercase** hex characters (`#a` matching is byte-exact, so an uppercase owner would be
|
||||||
|
* invisible to readers), and the repo `d` is non-empty and taken verbatim — the value splits
|
||||||
|
* on the first two colons only, so a repo `d` containing `:` stays addressable. The relay
|
||||||
|
* hint is never parsed or validated by content. Ground truth:
|
||||||
|
* `parse_project_member_coordinate` in Buzz's `buzz-relay/src/handlers/ingest.rs` and
|
||||||
|
* `ProjectMemberCoord` in `buzz-sdk/src/builders.rs`.
|
||||||
|
*/
|
||||||
|
object ProjectMemberTag {
|
||||||
|
const val TAG_NAME = "a"
|
||||||
|
const val MEMBER_KIND = GitRepositoryEvent.KIND
|
||||||
|
private const val MEMBER_KIND_SEGMENT = "30617"
|
||||||
|
|
||||||
|
/** Parses a strict member coordinate, or null when it is malformed. */
|
||||||
|
fun parseCoordinate(coordinate: String): Address? {
|
||||||
|
val parts = coordinate.split(':', limit = 3)
|
||||||
|
if (parts.size != 3) return null
|
||||||
|
val (kind, owner, repoD) = parts
|
||||||
|
if (kind != MEMBER_KIND_SEGMENT) return null
|
||||||
|
if (!isLowercaseHex64(owner)) return null
|
||||||
|
if (repoD.isEmpty()) return null
|
||||||
|
return Address(MEMBER_KIND, owner, repoD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidCoordinate(coordinate: String) = parseCoordinate(coordinate) != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a member tag with the arity (two or three elements) and coordinate rules the
|
||||||
|
* relay enforces; null for anything else.
|
||||||
|
*/
|
||||||
|
fun parse(tag: Tag): ProjectMember? {
|
||||||
|
if (tag.size !in 2..3 || tag[0] != TAG_NAME) return null
|
||||||
|
val address = parseCoordinate(tag[1]) ?: return null
|
||||||
|
return ProjectMember(address, tag.getOrNull(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(
|
||||||
|
coordinate: String,
|
||||||
|
relayHint: String? = null,
|
||||||
|
) = arrayOfNotNull(TAG_NAME, coordinate, relayHint)
|
||||||
|
|
||||||
|
fun assemble(member: ProjectMember) = assemble(member.coordinate, member.relayHint)
|
||||||
|
|
||||||
|
fun assemble(
|
||||||
|
owner: HexKey,
|
||||||
|
repoD: String,
|
||||||
|
relayHint: String? = null,
|
||||||
|
) = assemble("$MEMBER_KIND_SEGMENT:$owner:$repoD", relayHint)
|
||||||
|
|
||||||
|
private fun isLowercaseHex64(value: String): Boolean {
|
||||||
|
if (value.length != 64) return false
|
||||||
|
for (c in value) {
|
||||||
|
if (c !in '0'..'9' && c !in 'a'..'f') return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects.tags
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A NIP-MP project's listing visibility. Absent or unrecognized values read as [LISTED]: a
|
||||||
|
* typo in a metadata field is not a privacy signal, so it must never hide a project.
|
||||||
|
*/
|
||||||
|
enum class ProjectVisibility(
|
||||||
|
val code: String,
|
||||||
|
) {
|
||||||
|
LISTED("listed"),
|
||||||
|
UNLISTED("unlisted"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The writer-side parse: only the two defined values. */
|
||||||
|
fun parseStrict(code: String): ProjectVisibility? =
|
||||||
|
when (code) {
|
||||||
|
LISTED.code -> LISTED
|
||||||
|
UNLISTED.code -> UNLISTED
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reader-side interpretation: anything but `unlisted` is [LISTED]. */
|
||||||
|
fun interpret(code: String?): ProjectVisibility = if (code == UNLISTED.code) UNLISTED else LISTED
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.mpProjects.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NIP-MP `buzz-visibility` tag — `listed` (the default) or `unlisted`. The relay bounds
|
||||||
|
* it to [MAX_BYTES] and does not interpret it; readers apply [ProjectVisibility.interpret].
|
||||||
|
*/
|
||||||
|
object VisibilityTag {
|
||||||
|
const val TAG_NAME = "buzz-visibility"
|
||||||
|
const val MAX_BYTES = 256
|
||||||
|
|
||||||
|
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||||
|
|
||||||
|
/** The raw value, uninterpreted. */
|
||||||
|
fun parse(tag: Tag): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(visibility: ProjectVisibility) = arrayOf(TAG_NAME, visibility.code)
|
||||||
|
}
|
||||||
+78
@@ -95,5 +95,83 @@ data class AttestationConditions(
|
|||||||
|
|
||||||
/** True when [conditions] is a syntactically valid canonical conditions string. */
|
/** True when [conditions] is a syntactically valid canonical conditions string. */
|
||||||
fun isValid(conditions: String): Boolean = parse(conditions) != null
|
fun isValid(conditions: String): Boolean = parse(conditions) != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an authentication event signed at [authCreatedAt] satisfies every time clause
|
||||||
|
* in [conditions]. Both operators are strict (equality satisfies neither) and every clause
|
||||||
|
* must pass, including repeated ones — so this walks the raw string rather than [parse],
|
||||||
|
* which keeps only the last clause of each kind. `kind=` is deliberately ignored: Buzz
|
||||||
|
* treats the credential as connection-wide at admission. Ground truth:
|
||||||
|
* `verify_auth_tag_for_auth_event` in `buzz-sdk/src/nip_oa.rs`, enforced by the relay on
|
||||||
|
* NIP-42 AUTH, NIP-98 HTTP, Git and media admission.
|
||||||
|
*/
|
||||||
|
fun timeBoundsAllow(
|
||||||
|
conditions: String,
|
||||||
|
authCreatedAt: Long,
|
||||||
|
): Boolean {
|
||||||
|
if (conditions.isEmpty()) return true
|
||||||
|
for (clause in conditions.split("&")) {
|
||||||
|
when {
|
||||||
|
clause.startsWith("created_at<") -> {
|
||||||
|
val bound = clause.removePrefix("created_at<").toLongOrNull() ?: return false
|
||||||
|
if (authCreatedAt >= bound) return false
|
||||||
|
}
|
||||||
|
clause.startsWith("created_at>") -> {
|
||||||
|
val bound = clause.removePrefix("created_at>").toLongOrNull() ?: return false
|
||||||
|
if (authCreatedAt <= bound) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when every clause in [conditions] applies to an event of [kind] signed at
|
||||||
|
* [createdAt]: `kind=` must match and both time bounds hold (strictly). Unlike
|
||||||
|
* [timeBoundsAllow], `kind=` counts - this is the check for an attestation carried on an
|
||||||
|
* event (e.g. an agent's kind-0 profile), not for connection admission. Any clause it
|
||||||
|
* does not recognize fails. Mirrors `profile_valid_oa_owner_pubkey` in Buzz's
|
||||||
|
* `desktop/src-tauri/src/nostr_convert.rs`.
|
||||||
|
*/
|
||||||
|
fun appliesToEvent(
|
||||||
|
conditions: String,
|
||||||
|
kind: Int,
|
||||||
|
createdAt: Long,
|
||||||
|
): Boolean {
|
||||||
|
if (conditions.isEmpty()) return true
|
||||||
|
for (clause in conditions.split("&")) {
|
||||||
|
val ok =
|
||||||
|
when {
|
||||||
|
clause.startsWith("kind=") -> clause.removePrefix("kind=").toIntOrNull() == kind
|
||||||
|
clause.startsWith("created_at<") -> clause.removePrefix("created_at<").toLongOrNull()?.let { createdAt < it } ?: false
|
||||||
|
clause.startsWith("created_at>") -> clause.removePrefix("created_at>").toLongOrNull()?.let { createdAt > it } ?: false
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (!ok) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last second at which [conditions] still admit an authentication (the tightest
|
||||||
|
* `created_at<` bound minus one), or null when there is no upper bound.
|
||||||
|
*/
|
||||||
|
fun validUntil(conditions: String): Long? =
|
||||||
|
conditions
|
||||||
|
.split("&")
|
||||||
|
.mapNotNull { if (it.startsWith("created_at<")) it.removePrefix("created_at<").toLongOrNull() else null }
|
||||||
|
.minOrNull()
|
||||||
|
?.let { it - 1 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first second at which [conditions] admit an authentication (the tightest
|
||||||
|
* `created_at>` bound plus one), or null when there is no lower bound.
|
||||||
|
*/
|
||||||
|
fun validFrom(conditions: String): Long? =
|
||||||
|
conditions
|
||||||
|
.split("&")
|
||||||
|
.mapNotNull { if (it.startsWith("created_at>")) it.removePrefix("created_at>").toLongOrNull() else null }
|
||||||
|
.maxOrNull()
|
||||||
|
?.let { it + 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-5
@@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.core.isValid
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||||
import com.vitorpamplona.quartz.utils.Hex
|
|
||||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,26 +64,54 @@ data class OwnerAttestation(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies this attestation authorizes [agentPubKey]. Checks structural validity
|
* Verifies this attestation authorizes [agentPubKey]. Checks structural validity
|
||||||
* (valid hex keys, canonical conditions, no self-attestation) and then the owner's
|
* (lowercase hex keys and signature, canonical conditions, no self-attestation) and then
|
||||||
* Schnorr signature over the commitment hash.
|
* the owner's Schnorr signature over the commitment hash. Time clauses are NOT evaluated
|
||||||
|
* here; see [verifyForAuthAt].
|
||||||
*/
|
*/
|
||||||
fun verify(agentPubKey: HexKey): Boolean {
|
fun verify(agentPubKey: HexKey): Boolean {
|
||||||
if (!agentPubKey.isValid() || !ownerPubKey.isValid()) return false
|
if (!agentPubKey.isValid() || !ownerPubKey.isValid() || !isLowercaseHex(ownerPubKey, 64)) return false
|
||||||
// Self-attestation is explicitly prohibited: the owner must differ from the agent.
|
// Self-attestation is explicitly prohibited: the owner must differ from the agent.
|
||||||
if (ownerPubKey == agentPubKey) return false
|
if (ownerPubKey == agentPubKey) return false
|
||||||
if (!AttestationConditions.isValid(conditions)) return false
|
if (!AttestationConditions.isValid(conditions)) return false
|
||||||
if (sig.length != 128 || !Hex.isHex(sig)) return false
|
// Buzz rejects uppercase hex before it reaches its (permissive) decoder.
|
||||||
|
if (!isLowercaseHex(sig, 128)) return false
|
||||||
|
|
||||||
val message = sha256(commitment(agentPubKey).encodeToByteArray())
|
val message = sha256(commitment(agentPubKey).encodeToByteArray())
|
||||||
return Nip01Crypto.verify(sig.hexToByteArray(), message, ownerPubKey.hexToByteArray())
|
return Nip01Crypto.verify(sig.hexToByteArray(), message, ownerPubKey.hexToByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The check Buzz runs at admission: [verify], plus every `created_at<` / `created_at>`
|
||||||
|
* clause evaluated against the signed authentication event's [authCreatedAt] (strictly).
|
||||||
|
* An expired or not-yet-valid credential grants nothing, so the relay treats the agent as a
|
||||||
|
* non-member (`restricted`).
|
||||||
|
*/
|
||||||
|
fun verifyForAuthAt(
|
||||||
|
agentPubKey: HexKey,
|
||||||
|
authCreatedAt: Long,
|
||||||
|
): Boolean = verify(agentPubKey) && isValidAt(authCreatedAt)
|
||||||
|
|
||||||
|
/** True when the time clauses admit an authentication signed at [authCreatedAt]. */
|
||||||
|
fun isValidAt(authCreatedAt: Long): Boolean = AttestationConditions.timeBoundsAllow(conditions, authCreatedAt)
|
||||||
|
|
||||||
|
/** The last second this credential admits an authentication, or null when unbounded. */
|
||||||
|
fun validUntil(): Long? = AttestationConditions.validUntil(conditions)
|
||||||
|
|
||||||
|
/** The first second this credential admits an authentication, or null when unbounded. */
|
||||||
|
fun validFrom(): Long? = AttestationConditions.validFrom(conditions)
|
||||||
|
|
||||||
/** Builds the NIP-OA `auth` tag for this attestation. */
|
/** Builds the NIP-OA `auth` tag for this attestation. */
|
||||||
fun toTag(): Array<String> = AuthTag.assemble(this)
|
fun toTag(): Array<String> = AuthTag.assemble(this)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val COMMITMENT_PREFIX = "nostr:agent-auth:"
|
const val COMMITMENT_PREFIX = "nostr:agent-auth:"
|
||||||
|
|
||||||
|
/** Exactly [length] characters of lowercase hex, the only form Buzz accepts on the wire. */
|
||||||
|
fun isLowercaseHex(
|
||||||
|
value: String,
|
||||||
|
length: Int,
|
||||||
|
): Boolean = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }
|
||||||
|
|
||||||
/** SHA-256 of the commitment string — the 32-byte message the owner signs. */
|
/** SHA-256 of the commitment string — the 32-byte message the owner signs. */
|
||||||
fun commitmentHash(
|
fun commitmentHash(
|
||||||
agentPubKey: HexKey,
|
agentPubKey: HexKey,
|
||||||
@@ -119,6 +146,35 @@ data class OwnerAttestation(
|
|||||||
return sign(agentPubKey, conditions.encode(), priv)
|
return sign(agentPubKey, conditions.encode(), priv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner an agent's event declares through NIP-OA, or null. The event must carry
|
||||||
|
* **exactly one** `auth` tag (a malformed second one still counts, so there is no
|
||||||
|
* first-valid-tag fallback), it must verify for the event's own author [pubKey], and every
|
||||||
|
* condition must apply to this event ([AttestationConditions.appliesToEvent]).
|
||||||
|
*
|
||||||
|
* Buzz reads an agent's owner this way off the agent's kind-0 profile: that owner may edit
|
||||||
|
* the agent's channel messages, and names the agent in the UI. The event's own signature
|
||||||
|
* is the caller's to have checked. Ground truth: `profile_valid_oa_owner_pubkey` in Buzz's
|
||||||
|
* `desktop/src-tauri/src/nostr_convert.rs`.
|
||||||
|
*/
|
||||||
|
fun verifiedOwnerOf(
|
||||||
|
pubKey: HexKey,
|
||||||
|
kind: Int,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
): HexKey? {
|
||||||
|
var authTag: Array<String>? = null
|
||||||
|
for (tag in tags) {
|
||||||
|
if (tag.isEmpty() || tag[0] != AuthTag.TAG_NAME) continue
|
||||||
|
if (authTag != null) return null
|
||||||
|
authTag = tag
|
||||||
|
}
|
||||||
|
val attestation = AuthTag.parse(authTag ?: return null) ?: return null
|
||||||
|
if (!attestation.verify(pubKey)) return null
|
||||||
|
if (!AttestationConditions.appliesToEvent(attestation.conditions, kind, createdAt)) return null
|
||||||
|
return attestation.ownerPubKey
|
||||||
|
}
|
||||||
|
|
||||||
/** Parses a NIP-OA `auth` tag; see [AuthTag.parse]. Does not verify the signature. */
|
/** Parses a NIP-OA `auth` tag; see [AuthTag.parse]. Does not verify the signature. */
|
||||||
fun parse(tag: Tag): OwnerAttestation? = AuthTag.parse(tag)
|
fun parse(tag: Tag): OwnerAttestation? = AuthTag.parse(tag)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
|||||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
import com.vitorpamplona.quartz.utils.Hex
|
|
||||||
import com.vitorpamplona.quartz.utils.ensure
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,21 +42,22 @@ object AuthTag {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a well-formed `auth` tag into an [OwnerAttestation]. Returns null on any
|
* Parses a well-formed `auth` tag into an [OwnerAttestation]. Returns null on any
|
||||||
* structural problem: wrong name/arity, an owner pubkey that is not 64-char hex, a
|
* structural problem: wrong name or not exactly 4 elements, an owner pubkey that is not
|
||||||
* signature that is not 128-char hex, or non-canonical conditions. This is a
|
* 64-char lowercase hex, a signature that is not 128-char lowercase hex, or non-canonical
|
||||||
|
* conditions (mirrors `parse_auth_tag_fields` in Buzz's `buzz-sdk/src/nip_oa.rs`). This is a
|
||||||
* *shape* check only — it does not verify the signature (call
|
* *shape* check only — it does not verify the signature (call
|
||||||
* [OwnerAttestation.verify]).
|
* [OwnerAttestation.verify]).
|
||||||
*/
|
*/
|
||||||
fun parse(tag: Array<String>): OwnerAttestation? {
|
fun parse(tag: Array<String>): OwnerAttestation? {
|
||||||
ensure(tag.has(3)) { return null }
|
ensure(tag.size == 4) { return null }
|
||||||
ensure(tag[0] == TAG_NAME) { return null }
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
|
||||||
val owner = tag[1]
|
val owner = tag[1]
|
||||||
val conditions = tag[2]
|
val conditions = tag[2]
|
||||||
val sig = tag[3]
|
val sig = tag[3]
|
||||||
|
|
||||||
ensure(Hex.isHex64(owner)) { return null }
|
ensure(OwnerAttestation.isLowercaseHex(owner, 64)) { return null }
|
||||||
ensure(sig.length == 128 && Hex.isHex(sig)) { return null }
|
ensure(OwnerAttestation.isLowercaseHex(sig, 128)) { return null }
|
||||||
ensure(AttestationConditions.isValid(conditions)) { return null }
|
ensure(AttestationConditions.isValid(conditions)) { return null }
|
||||||
|
|
||||||
return OwnerAttestation(owner, conditions, sig)
|
return OwnerAttestation(owner, conditions, sig)
|
||||||
|
|||||||
+6
-4
@@ -67,13 +67,15 @@ open class BuzzMembershipPolicy(
|
|||||||
/**
|
/**
|
||||||
* Runs after the NIP-42 proof checks out (see [FullAuthPolicy.authorize]). If the auth event
|
* Runs after the NIP-42 proof checks out (see [FullAuthPolicy.authorize]). If the auth event
|
||||||
* carries an owner-signed attestation authorizing this agent, and the owner is a member,
|
* carries an owner-signed attestation authorizing this agent, and the owner is a member,
|
||||||
* remember the agent as a member for this connection. We never throw here — a missing or
|
* remember the agent as a member for this connection. Like Buzz, the credential's
|
||||||
* invalid attestation just means the key is authenticated but not (yet) authorized, which the
|
* `created_at<` / `created_at>` clauses are checked against the AUTH event's own `created_at`,
|
||||||
* membership gate below handles.
|
* so an expired or not-yet-valid attestation grants nothing. We never throw here — a missing
|
||||||
|
* or invalid attestation just means the key is authenticated but not (yet) authorized, which
|
||||||
|
* the membership gate below handles.
|
||||||
*/
|
*/
|
||||||
override suspend fun authorize(event: RelayAuthEvent) {
|
override suspend fun authorize(event: RelayAuthEvent) {
|
||||||
val attestation = event.tags.firstNotNullOfOrNull(AuthTag::parse) ?: return
|
val attestation = event.tags.firstNotNullOfOrNull(AuthTag::parse) ?: return
|
||||||
if (attestation.ownerPubKey in members && attestation.verify(event.pubKey)) {
|
if (attestation.ownerPubKey in members && attestation.verifyForAuthAt(event.pubKey, event.createdAt)) {
|
||||||
authorizedAgents.add(event.pubKey)
|
authorizedAgents.add(event.pubKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-5
@@ -22,10 +22,12 @@ package com.vitorpamplona.quartz.buzz.rsReadState
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
||||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +38,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
* `kind:30078` [com.vitorpamplona.quartz.nip01Core.core.Event] subclass — a second class on the
|
* `kind:30078` [com.vitorpamplona.quartz.nip01Core.core.Event] subclass — a second class on the
|
||||||
* same kind would break [com.vitorpamplona.quartz.utils.EventFactory] dispatch. Instead it is a
|
* same kind would break [com.vitorpamplona.quartz.utils.EventFactory] dispatch. Instead it is a
|
||||||
* thin content/tag layer *on top of* [AppSpecificDataEvent]:
|
* thin content/tag layer *on top of* [AppSpecificDataEvent]:
|
||||||
* - the `d` tag is `read-state:<slot-id>` ([dTagFor] / [slotIdFrom]),
|
* - the `d` tag is `read-state:<slot-id>` ([dTagFor] / [slotIdFrom]), where `<slot-id>` is exactly
|
||||||
|
* 32 lowercase hex characters ([isValidSlotId]) — any other shape MUST be ignored, since the
|
||||||
|
* fixed shape is what lets a relay recognize a read-state coordinate structurally,
|
||||||
* - a single `["t", "read-state"]` tag enables relay-side filtering,
|
* - a single `["t", "read-state"]` tag enables relay-side filtering,
|
||||||
* - `content` is a NIP-44 self-encrypted [ReadStateContent] (conversation key
|
* - `content` is a NIP-44 self-encrypted [ReadStateContent] (conversation key
|
||||||
* `nip44(user_privkey, user_pubkey)` — the user's own key on both sides).
|
* `nip44(user_privkey, user_pubkey)` — the user's own key on both sides).
|
||||||
@@ -47,18 +51,36 @@ object ReadState {
|
|||||||
const val D_TAG_PREFIX = "read-state:"
|
const val D_TAG_PREFIX = "read-state:"
|
||||||
const val T_TAG_VALUE = "read-state"
|
const val T_TAG_VALUE = "read-state"
|
||||||
|
|
||||||
|
/** A `<slot-id>` is exactly this many lowercase hex characters (16 random bytes). */
|
||||||
|
const val SLOT_ID_LENGTH = 32
|
||||||
|
|
||||||
|
/** True when [slotId] is exactly 32 lowercase hex characters (`[0-9a-f]{32}`). */
|
||||||
|
fun isValidSlotId(slotId: String): Boolean = slotId.length == SLOT_ID_LENGTH && slotId.all { it in '0'..'9' || it in 'a'..'f' }
|
||||||
|
|
||||||
|
/** A fresh random `<slot-id>`: 16 secure random bytes as lowercase hex. */
|
||||||
|
fun newSlotId(): String = RandomInstance.bytes(SLOT_ID_LENGTH / 2).toHexKey()
|
||||||
|
|
||||||
/** The addressable `d`-tag value for a given slot id. */
|
/** The addressable `d`-tag value for a given slot id. */
|
||||||
fun dTagFor(slotId: String): String = "$D_TAG_PREFIX$slotId"
|
fun dTagFor(slotId: String): String = "$D_TAG_PREFIX$slotId"
|
||||||
|
|
||||||
/** Extracts the `<slot-id>` from a `read-state:<slot-id>` d-tag value, or `null` if it does not match. */
|
/**
|
||||||
fun slotIdFrom(dTagValue: String): String? = dTagValue.removePrefix(D_TAG_PREFIX).takeIf { it != dTagValue && it.isNotEmpty() }
|
* Extracts the `<slot-id>` from a `read-state:<slot-id>` d-tag value, or `null` if it does not
|
||||||
|
* match — including a slot id that is not exactly 32 lowercase hex characters.
|
||||||
|
*/
|
||||||
|
fun slotIdFrom(dTagValue: String): String? {
|
||||||
|
if (!dTagValue.startsWith(D_TAG_PREFIX)) return null
|
||||||
|
return dTagValue.substring(D_TAG_PREFIX.length).takeIf(::isValidSlotId)
|
||||||
|
}
|
||||||
|
|
||||||
/** True if [tags] are a well-formed NIP-RS coordinate: exactly one `read-state:` d-tag and one `["t","read-state"]`. */
|
/**
|
||||||
|
* True if [tags] are a well-formed NIP-RS coordinate: exactly one `d` tag, holding
|
||||||
|
* `read-state:<32 lowercase hex>`, and exactly one `["t","read-state"]`.
|
||||||
|
*/
|
||||||
fun isReadState(tags: TagArray): Boolean {
|
fun isReadState(tags: TagArray): Boolean {
|
||||||
val dTags = tags.count { it.size > 1 && it[0] == "d" }
|
val dTags = tags.count { it.size > 1 && it[0] == "d" }
|
||||||
val readStateT = tags.count { it.size > 1 && it[0] == HashtagTag.TAG_NAME && it[1] == T_TAG_VALUE }
|
val readStateT = tags.count { it.size > 1 && it[0] == HashtagTag.TAG_NAME && it[1] == T_TAG_VALUE }
|
||||||
val dValue = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.getOrNull(1)
|
val dValue = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.getOrNull(1)
|
||||||
return dTags == 1 && readStateT == 1 && dValue != null && dValue.startsWith(D_TAG_PREFIX)
|
return dTags == 1 && readStateT == 1 && dValue != null && slotIdFrom(dValue) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds the NIP-RS `["t", "read-state"]` filter tag to an [AppSpecificDataEvent] builder. */
|
/** Adds the NIP-RS `["t", "read-state"]` filter tag to an [AppSpecificDataEvent] builder. */
|
||||||
@@ -74,6 +96,7 @@ object ReadState {
|
|||||||
signer: NostrSigner,
|
signer: NostrSigner,
|
||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
): AppSpecificDataEvent {
|
): AppSpecificDataEvent {
|
||||||
|
require(isValidSlotId(slotId)) { "NIP-RS slot id must be 32 lowercase hex characters" }
|
||||||
val ciphertext = signer.nip44Encrypt(content.encodeToJson(), signer.pubKey)
|
val ciphertext = signer.nip44Encrypt(content.encodeToJson(), signer.pubKey)
|
||||||
return signer.sign(
|
return signer.sign(
|
||||||
AppSpecificDataEvent.build(dTagFor(slotId), ciphertext, createdAt) {
|
AppSpecificDataEvent.build(dTagFor(slotId), ciphertext, createdAt) {
|
||||||
|
|||||||
+145
-2
@@ -22,9 +22,11 @@ package com.vitorpamplona.quartz.buzz.rsReadState
|
|||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The decrypted NIP-RS (Buzz cross-device read-state, `docs/nips/NIP-RS.md`) blob: a per-client
|
* The decrypted NIP-RS (Buzz cross-device read-state, `docs/nips/NIP-RS.md`) blob: a per-client
|
||||||
@@ -34,6 +36,18 @@ import kotlinx.serialization.json.Json
|
|||||||
* blobs with an unknown [v]; [contexts] values are "all messages in this context at or before
|
* blobs with an unknown [v]; [contexts] values are "all messages in this context at or before
|
||||||
* this time are read." This is the plaintext that gets NIP-44 self-encrypted into an
|
* this time are read." This is the plaintext that gets NIP-44 self-encrypted into an
|
||||||
* [com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent] `content` — see [ReadState].
|
* [com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent] `content` — see [ReadState].
|
||||||
|
*
|
||||||
|
* [contexts] is the **wire** map. It mixes two kinds of entry:
|
||||||
|
* - frontier entries, keyed by a context id escaped with [ReadStateKeys.escape] (an id that
|
||||||
|
* starts with the reserved `ov_` / `esc:` gets one `esc:` prepended) — read them with [frontiers];
|
||||||
|
* - the manual-unread override layer's `ov_s:` / `ov_c:` / `ov_b:` counters — read them with
|
||||||
|
* [overrides], build them with [build].
|
||||||
|
*
|
||||||
|
* [decodeFromJson] applies NIP-RS "Content Validation": the blob is rejected as a whole only for a
|
||||||
|
* structural fault (not an object, missing/non-integer `v`, missing `client_id` or `contexts`, more
|
||||||
|
* than [MAX_CONTEXT_ENTRIES] entries); a bad individual entry (a value outside `0..2^32-1`, a key
|
||||||
|
* over [MAX_KEY_BYTES] bytes) is dropped and the rest is kept, and override counters are validated
|
||||||
|
* as a complete per-context group before anything else touches them.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ReadStateContent(
|
data class ReadStateContent(
|
||||||
@@ -46,9 +60,48 @@ data class ReadStateContent(
|
|||||||
/** NIP-RS validity: the schema version is the one this client understands and `client_id` is 1–64 chars. */
|
/** NIP-RS validity: the schema version is the one this client understands and `client_id` is 1–64 chars. */
|
||||||
fun isSupported(): Boolean = v == CURRENT_VERSION && clientId.length in 1..64
|
fun isSupported(): Boolean = v == CURRENT_VERSION && clientId.length in 1..64
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read frontiers keyed by **raw** context id: escaped wire keys are unescaped, and the
|
||||||
|
* reserved `ov_` override entries are left out.
|
||||||
|
*/
|
||||||
|
fun frontiers(): Map<String, Long> {
|
||||||
|
val result = LinkedHashMap<String, Long>()
|
||||||
|
contexts.forEach { (key, value) ->
|
||||||
|
if (!ReadStateKeys.isReservedWireKey(key)) result[ReadStateKeys.unescape(key)] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manual-unread override registers keyed by raw context id. Every group that survived
|
||||||
|
* [decodeFromJson] is complete — a live `ov_s`/`ov_c`/`ov_b` triple or a lone `ov_c` tombstone
|
||||||
|
* floor — and every group has an `ov_c`, so that key anchors the lookup.
|
||||||
|
*/
|
||||||
|
fun overrides(): Map<String, OverrideRegister> {
|
||||||
|
val result = LinkedHashMap<String, OverrideRegister>()
|
||||||
|
contexts.forEach { (key, clear) ->
|
||||||
|
if (key.startsWith(ReadStateKeys.OV_CLEAR)) {
|
||||||
|
val ctx = key.substring(ReadStateKeys.OV_CLEAR.length)
|
||||||
|
val set = contexts[ReadStateKeys.OV_SET + ctx] ?: 0L
|
||||||
|
val baseline = contexts[ReadStateKeys.OV_BASELINE + ctx] ?: 0L
|
||||||
|
result[ctx] = OverrideRegister(set, clear, baseline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CURRENT_VERSION = 1
|
const val CURRENT_VERSION = 1
|
||||||
|
|
||||||
|
/** A blob with more context entries than this is rejected outright. */
|
||||||
|
const val MAX_CONTEXT_ENTRIES = 10_000
|
||||||
|
|
||||||
|
/** A context key longer than this many UTF-8 bytes is dropped (for an `ov_` key: its whole group). */
|
||||||
|
const val MAX_KEY_BYTES = 256
|
||||||
|
|
||||||
|
/** The largest timestamp / counter value: `2^32 - 1`. */
|
||||||
|
const val MAX_VALUE = 4_294_967_295L
|
||||||
|
|
||||||
val JSON =
|
val JSON =
|
||||||
Json {
|
Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@@ -56,6 +109,96 @@ data class ReadStateContent(
|
|||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decodeFromJson(json: String): ReadStateContent = JSON.decodeFromString(json)
|
/**
|
||||||
|
* Parses and validates a decrypted NIP-RS blob. Throws [IllegalArgumentException] (or a
|
||||||
|
* serialization exception for malformed JSON) when the whole blob must be discarded. A blob
|
||||||
|
* with an unknown [v] parses with empty [contexts] and fails [isSupported] — NIP-RS says to
|
||||||
|
* ignore it, and its `contexts` may not even follow this schema.
|
||||||
|
*/
|
||||||
|
fun decodeFromJson(json: String): ReadStateContent {
|
||||||
|
val root = JSON.parseToJsonElement(json) as? JsonObject ?: throw IllegalArgumentException("NIP-RS blob is not a JSON object")
|
||||||
|
|
||||||
|
val v = (root["v"] as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toIntOrNull()
|
||||||
|
requireNotNull(v) { "NIP-RS blob has a missing or non-integer v" }
|
||||||
|
|
||||||
|
val clientId = (root["client_id"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
requireNotNull(clientId) { "NIP-RS blob has no client_id string" }
|
||||||
|
|
||||||
|
if (v != CURRENT_VERSION) return ReadStateContent(v, clientId, emptyMap())
|
||||||
|
|
||||||
|
val contexts = root["contexts"] as? JsonObject ?: throw IllegalArgumentException("NIP-RS blob has no contexts object")
|
||||||
|
require(contexts.size <= MAX_CONTEXT_ENTRIES) { "NIP-RS blob has more than $MAX_CONTEXT_ENTRIES context entries" }
|
||||||
|
|
||||||
|
return ReadStateContent(v, clientId, sanitizeContexts(contexts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a blob from raw context ids: [frontiers] are written under their escaped keys, and
|
||||||
|
* each of [overrides] is canonicalized against its context's frontier first (NIP-RS
|
||||||
|
* "Mandatory Canonical Publication") — a live register as its three keys, a dead one as a
|
||||||
|
* lone `ov_c` tombstone floor, a virgin one not at all.
|
||||||
|
*/
|
||||||
|
fun build(
|
||||||
|
clientId: String,
|
||||||
|
frontiers: Map<String, Long>,
|
||||||
|
overrides: Map<String, OverrideRegister> = emptyMap(),
|
||||||
|
): ReadStateContent {
|
||||||
|
val contexts = LinkedHashMap<String, Long>()
|
||||||
|
frontiers.forEach { (ctx, ts) -> contexts[ReadStateKeys.escape(ctx)] = ts }
|
||||||
|
overrides.forEach { (ctx, register) ->
|
||||||
|
register.canonicalize(frontiers[ctx] ?: 0L)?.let { canonical ->
|
||||||
|
if (canonical.isTombstone()) {
|
||||||
|
contexts[ReadStateKeys.OV_CLEAR + ctx] = canonical.clear
|
||||||
|
} else {
|
||||||
|
contexts[ReadStateKeys.OV_SET + ctx] = canonical.set
|
||||||
|
contexts[ReadStateKeys.OV_CLEAR + ctx] = canonical.clear
|
||||||
|
contexts[ReadStateKeys.OV_BASELINE + ctx] = canonical.baseline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ReadStateContent(CURRENT_VERSION, clientId, contexts)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeContexts(contexts: JsonObject): Map<String, Long> {
|
||||||
|
val result = LinkedHashMap<String, Long>()
|
||||||
|
// Override counters are validated as a group BEFORE any per-entry rule: NIP-RS forbids
|
||||||
|
// dropping one bad sibling and keeping the rest (a partial group rebuilds a wrong register).
|
||||||
|
val groups = LinkedHashMap<String, MutableMap<String, JsonElement>>()
|
||||||
|
|
||||||
|
contexts.forEach { (key, value) ->
|
||||||
|
val prefix = ReadStateKeys.overridePrefixOf(key)
|
||||||
|
if (prefix != null) {
|
||||||
|
groups.getOrPut(key.substring(prefix.length)) { LinkedHashMap() }[prefix] = value
|
||||||
|
} else {
|
||||||
|
val ts = value.asUInt32()
|
||||||
|
if (ts != null && key.encodeToByteArray().size <= MAX_KEY_BYTES) result[key] = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.forEach { (ctx, members) ->
|
||||||
|
val isLive = members.size == 3
|
||||||
|
val isTombstone = members.size == 1 && ReadStateKeys.OV_CLEAR in members
|
||||||
|
if (!isLive && !isTombstone) return@forEach
|
||||||
|
|
||||||
|
val values = LinkedHashMap<String, Long>()
|
||||||
|
members.forEach { (prefix, value) ->
|
||||||
|
val key = prefix + ctx
|
||||||
|
val counter = value.asUInt32() ?: return@forEach
|
||||||
|
if (key.encodeToByteArray().size > MAX_KEY_BYTES) return@forEach
|
||||||
|
values[key] = counter
|
||||||
|
}
|
||||||
|
// One invalid sibling rejects the whole group (its frontier entry is kept).
|
||||||
|
if (values.size == members.size) result.putAll(values)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An integer JSON number in `0..2^32-1`, or null (a string, a fraction, out of range, null…). */
|
||||||
|
private fun JsonElement.asUInt32(): Long? {
|
||||||
|
val primitive = this as? JsonPrimitive ?: return null
|
||||||
|
if (primitive.isString) return null
|
||||||
|
val value = primitive.content.toLongOrNull() ?: return null
|
||||||
|
return value.takeIf { it in 0..MAX_VALUE }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.rsReadState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-RS "Reserved Namespace": the key vocabulary of a read-state `contexts` map.
|
||||||
|
*
|
||||||
|
* The `ov_` stem belongs to the manual-unread override layer and `esc:` is its escape marker, so a
|
||||||
|
* raw context id starting with either is written with one extra leading `esc:` ([escape]) and read
|
||||||
|
* back by stripping exactly one ([unescape]) — a bijection. The `ov_s:`/`ov_c:`/`ov_b:` keys carry
|
||||||
|
* the **unescaped** raw context id as their suffix.
|
||||||
|
*/
|
||||||
|
object ReadStateKeys {
|
||||||
|
const val RESERVED_STEM = "ov_"
|
||||||
|
const val ESCAPE = "esc:"
|
||||||
|
|
||||||
|
/** Set counter S of a context's override register. */
|
||||||
|
const val OV_SET = "ov_s:"
|
||||||
|
|
||||||
|
/** Clear counter C of a context's override register (also the lone key of a tombstone floor). */
|
||||||
|
const val OV_CLEAR = "ov_c:"
|
||||||
|
|
||||||
|
/** Baseline B: the effective frontier when the context was last marked unread. */
|
||||||
|
const val OV_BASELINE = "ov_b:"
|
||||||
|
|
||||||
|
/** The frontier wire key for raw context id [ctx]. */
|
||||||
|
fun escape(ctx: String): String = if (ctx.startsWith(RESERVED_STEM) || ctx.startsWith(ESCAPE)) ESCAPE + ctx else ctx
|
||||||
|
|
||||||
|
/** The raw context id behind frontier wire key [wireKey]: strips exactly one leading `esc:`. */
|
||||||
|
fun unescape(wireKey: String): String = if (wireKey.startsWith(ESCAPE)) wireKey.substring(ESCAPE.length) else wireKey
|
||||||
|
|
||||||
|
/** True for a wire key in the reserved `ov_` namespace — never a frontier entry. */
|
||||||
|
fun isReservedWireKey(wireKey: String): Boolean = wireKey.startsWith(RESERVED_STEM)
|
||||||
|
|
||||||
|
/** The override prefix ([OV_SET], [OV_CLEAR] or [OV_BASELINE]) [wireKey] starts with, or null. */
|
||||||
|
fun overridePrefixOf(wireKey: String): String? =
|
||||||
|
when {
|
||||||
|
wireKey.startsWith(OV_SET) -> OV_SET
|
||||||
|
wireKey.startsWith(OV_CLEAR) -> OV_CLEAR
|
||||||
|
wireKey.startsWith(OV_BASELINE) -> OV_BASELINE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A NIP-RS manual-unread override register `(S, C, B)` for one context: [set] counts mark-unread
|
||||||
|
* actions, [clear] counts explicit mark-reads, and [baseline] is the effective frontier captured at
|
||||||
|
* the most recent mark-unread. Registers merge by componentwise max ([merge]); the override is live
|
||||||
|
* only while `S > 0 && F <= B && S > C` ([isActive]) — clear wins ties.
|
||||||
|
*
|
||||||
|
* Every value is a uint32 (`0..2^32-1`); a counter that would pass that is refused, never wrapped.
|
||||||
|
*/
|
||||||
|
data class OverrideRegister(
|
||||||
|
val set: Long,
|
||||||
|
val clear: Long,
|
||||||
|
val baseline: Long,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(set in 0..ReadStateContent.MAX_VALUE && clear in 0..ReadStateContent.MAX_VALUE && baseline in 0..ReadStateContent.MAX_VALUE) {
|
||||||
|
"override counters must be uint32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never activated: publishes nothing. */
|
||||||
|
fun isVirgin(): Boolean = set == 0L && clear == 0L
|
||||||
|
|
||||||
|
/** The tombstone-floor shape `(0, C, 0)` with `C > 0`: published as a lone `ov_c` key. */
|
||||||
|
fun isTombstone(): Boolean = set == 0L && baseline == 0L && clear > 0L
|
||||||
|
|
||||||
|
/** The liveness predicate against the merged effective [frontier]. */
|
||||||
|
fun isActive(frontier: Long): Boolean = set > 0 && frontier <= baseline && set > clear
|
||||||
|
|
||||||
|
/** Componentwise max — the only merge rule. */
|
||||||
|
fun merge(other: OverrideRegister): OverrideRegister = OverrideRegister(maxOf(set, other.set), maxOf(clear, other.clear), maxOf(baseline, other.baseline))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The register as it must be published against [frontier]: unchanged while live, compacted to
|
||||||
|
* the tombstone floor `(0, max(S, C), 0)` once dead, and null (omit it) when virgin.
|
||||||
|
*/
|
||||||
|
fun canonicalize(frontier: Long): OverrideRegister? =
|
||||||
|
when {
|
||||||
|
isVirgin() -> null
|
||||||
|
isActive(frontier) -> this
|
||||||
|
else -> OverrideRegister(0, maxOf(set, clear), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark-unread at the current effective [frontier]: `S = max(S, C) + 1`, `B = frontier`. Null when
|
||||||
|
* `max(S, C)` is already the uint32 maximum — the action must then be refused.
|
||||||
|
*/
|
||||||
|
fun markUnread(frontier: Long): OverrideRegister? {
|
||||||
|
val top = maxOf(set, clear)
|
||||||
|
if (top >= ReadStateContent.MAX_VALUE) return null
|
||||||
|
return OverrideRegister(top + 1, clear, frontier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit mark-read, evaluated against the frontier *after* the caller advanced it
|
||||||
|
* ([frontierAfter]): `C = max(S, C) + 1`. At the uint32 ceiling the counters stay as they are and
|
||||||
|
* the action succeeds only if the override is already inactive; null means it must be reported
|
||||||
|
* as failed.
|
||||||
|
*/
|
||||||
|
fun markRead(frontierAfter: Long): OverrideRegister? {
|
||||||
|
val top = maxOf(set, clear)
|
||||||
|
if (top < ReadStateContent.MAX_VALUE) return OverrideRegister(set, top + 1, baseline)
|
||||||
|
return if (isActive(frontierAfter)) null else this
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val VIRGIN = OverrideRegister(0, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effective read state across every supported blob a user published: frontiers and override
|
||||||
|
* registers each merged by max. [isUnread] is the NIP-RS verdict
|
||||||
|
* `latest_message_ts > F || override_active(S, C, B, F)`.
|
||||||
|
*/
|
||||||
|
class MergedReadState(
|
||||||
|
val frontiers: Map<String, Long>,
|
||||||
|
val overrides: Map<String, OverrideRegister>,
|
||||||
|
) {
|
||||||
|
/** The merged frontier of raw context [ctx], or 0 when none was published. */
|
||||||
|
fun frontier(ctx: String): Long = frontiers[ctx] ?: 0L
|
||||||
|
|
||||||
|
/** True while [ctx] carries a live manual-unread override. */
|
||||||
|
fun isOverrideActive(ctx: String): Boolean = overrides[ctx]?.isActive(frontier(ctx)) == true
|
||||||
|
|
||||||
|
/** Whether [ctx] reads as unread given the `created_at` of its newest message. */
|
||||||
|
fun isUnread(
|
||||||
|
ctx: String,
|
||||||
|
latestMessageTs: Long,
|
||||||
|
): Boolean = latestMessageTs > frontier(ctx) || isOverrideActive(ctx)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Merges [blobs], skipping any that fail [ReadStateContent.isSupported]. */
|
||||||
|
fun merge(blobs: List<ReadStateContent>): MergedReadState {
|
||||||
|
val frontiers = LinkedHashMap<String, Long>()
|
||||||
|
val overrides = LinkedHashMap<String, OverrideRegister>()
|
||||||
|
blobs.forEach { blob ->
|
||||||
|
if (!blob.isSupported()) return@forEach
|
||||||
|
blob.frontiers().forEach { (ctx, ts) ->
|
||||||
|
val current = frontiers[ctx]
|
||||||
|
if (current == null || ts > current) frontiers[ctx] = ts
|
||||||
|
}
|
||||||
|
blob.overrides().forEach { (ctx, register) ->
|
||||||
|
overrides[ctx] = overrides[ctx]?.merge(register) ?: register
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MergedReadState(frontiers, overrides)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.stream
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||||
|
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Buzz channel message: a plain NIP-29 kind-9 [ChatEvent] in Buzz's tag shape. This is what every
|
||||||
|
* Buzz client writes today (desktop, mobile, CLI); kind 40002 ([StreamMessageV2Event]) is only a
|
||||||
|
* read-compat tail from the 10002 -> 40001 -> 40002 migration.
|
||||||
|
*
|
||||||
|
* Mirrors `build_message` in `buzz-sdk/src/builders.rs`, in its tag order:
|
||||||
|
* - `["h", channel]`
|
||||||
|
* - the NIP-10 thread markers when it is a reply ([buzzThread]: one `reply` marker for a direct
|
||||||
|
* reply, `root` + `reply` for a nested one) - derive [threadRoot] with
|
||||||
|
* `buzzThreadRootForReplyTo` on the parent or the relay rejects the ancestry
|
||||||
|
* - one `p` per mention (the author may mention themselves)
|
||||||
|
* - `["broadcast", "1"]` for a reply that should also show in the channel timeline instead of
|
||||||
|
* only in its thread
|
||||||
|
*
|
||||||
|
* Media (`imeta`) and NIP-30 `emoji` tags follow via [initializer].
|
||||||
|
*/
|
||||||
|
object BuzzChatMessage {
|
||||||
|
fun build(
|
||||||
|
channelId: String,
|
||||||
|
content: String,
|
||||||
|
threadRoot: HexKey? = null,
|
||||||
|
replyTo: HexKey? = null,
|
||||||
|
mentions: List<HexKey> = emptyList(),
|
||||||
|
broadcast: Boolean = false,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<ChatEvent>.() -> Unit = {},
|
||||||
|
) = ChatEvent.build(content, createdAt) {
|
||||||
|
hTag(channelId)
|
||||||
|
if (replyTo != null) buzzThread(threadRoot ?: replyTo, replyTo)
|
||||||
|
mentions(mentions)
|
||||||
|
if (broadcast) broadcast()
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.stream
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tags a Buzz message renders with once a kind-40003 edit is applied: the original message's
|
||||||
|
* tags with the edit's attachments, custom emoji and added mentions overlaid. Buzz's clients
|
||||||
|
* resolve this on read (the relay stores the edit as-is), so this mirrors `applyEditTagOverlay`
|
||||||
|
* in Buzz's `desktop/src/features/messages/lib/applyEditTagOverlay.mjs`, in its output order:
|
||||||
|
*
|
||||||
|
* - `imeta` comes **only** from the edit. The edit re-sends the full attachment set, so an edit
|
||||||
|
* without any `imeta` means "no attachments".
|
||||||
|
* - `emoji` (NIP-30) comes from the edit when the edit has any, otherwise the original's stay:
|
||||||
|
* an older or foreign client's edit carries none, and dropping them would break a `:shortcode:`
|
||||||
|
* the original rendered.
|
||||||
|
* - `p` tags in the edit are added to the original's (only newly added mentions notify).
|
||||||
|
* - `mention` reference tags are replaced by the edit's when it carries `["buzz:mention-snapshot"]`,
|
||||||
|
* except the send-time `agent-address` ones, which always stay.
|
||||||
|
* - everything else (`h`, `e`, thread markers, `broadcast`, …) comes from the original only, so an
|
||||||
|
* edit can't move a message to another channel or thread.
|
||||||
|
*/
|
||||||
|
object BuzzEditTagOverlay {
|
||||||
|
const val IMETA = "imeta"
|
||||||
|
const val EMOJI = "emoji"
|
||||||
|
const val PUBKEY = "p"
|
||||||
|
const val MENTION = "mention"
|
||||||
|
const val MENTION_SNAPSHOT = "buzz:mention-snapshot"
|
||||||
|
const val AGENT_ADDRESS_MARKER = "agent-address"
|
||||||
|
|
||||||
|
private fun isAgentAddressMention(tag: Array<String>) = tag.size > 2 && tag[0] == MENTION && tag[2] == AGENT_ADDRESS_MARKER
|
||||||
|
|
||||||
|
fun apply(
|
||||||
|
originalTags: TagArray,
|
||||||
|
editTags: TagArray?,
|
||||||
|
): TagArray {
|
||||||
|
if (editTags == null) return originalTags
|
||||||
|
|
||||||
|
val editEmoji = editTags.filter { it.isNotEmpty() && it[0] == EMOJI }
|
||||||
|
val hasMentionSnapshot = editTags.any { it.isNotEmpty() && it[0] == MENTION_SNAPSHOT }
|
||||||
|
val editMentions = editTags.filter { it.isNotEmpty() && it[0] == MENTION && !isAgentAddressMention(it) }
|
||||||
|
|
||||||
|
val fromOriginal =
|
||||||
|
originalTags.filter { tag ->
|
||||||
|
if (tag.isEmpty()) return@filter true
|
||||||
|
when {
|
||||||
|
tag[0] == IMETA -> false
|
||||||
|
editEmoji.isNotEmpty() && tag[0] == EMOJI -> false
|
||||||
|
hasMentionSnapshot && tag[0] == MENTION && !isAgentAddressMention(tag) -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fromEdit = editTags.filter { it.isNotEmpty() && (it[0] == IMETA || it[0] == PUBKEY || it[0] == MENTION_SNAPSHOT) }
|
||||||
|
|
||||||
|
return (fromOriginal + fromEdit + editEmoji + editMentions).toTypedArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.quartz.buzz.stream
|
package com.vitorpamplona.quartz.buzz.stream
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.buzz.stream.tags.ExpectedRevisionTag
|
||||||
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
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
@@ -33,7 +34,13 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
* A Buzz channel canvas (shared markdown document), `kind:40100`.
|
* A Buzz channel canvas (shared markdown document), `kind:40100`.
|
||||||
*
|
*
|
||||||
* Mirrors `build_set_canvas` in Buzz's `buzz-sdk/src/builders.rs`: a single `h`
|
* Mirrors `build_set_canvas` in Buzz's `buzz-sdk/src/builders.rs`: a single `h`
|
||||||
* channel tag with the markdown document in [content].
|
* channel tag with the markdown document in [content], plus an optional
|
||||||
|
* `["expected-revision", …]` compare-and-swap precondition ([ExpectedRevisionTag]).
|
||||||
|
*
|
||||||
|
* The live canvas is the newest revision under `created_at DESC, id ASC` (a same-second tie goes
|
||||||
|
* to the smallest id — see [isNewerHeadThan]). A write that edits a loaded head stamps
|
||||||
|
* `created_at = max(now, head.created_at + 1)` ([writeCreatedAt]) so it always sorts strictly
|
||||||
|
* ahead of the head it asserts.
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
class CanvasEvent(
|
class CanvasEvent(
|
||||||
@@ -55,17 +62,82 @@ class CanvasEvent(
|
|||||||
|
|
||||||
fun channel() = tags.channel()
|
fun channel() = tags.channel()
|
||||||
|
|
||||||
|
/** The `expected-revision` precondition: [ExpectedRevisionTag.NONE], a head id, or null for an unconditional write. */
|
||||||
|
fun expectedRevision() = tags.firstNotNullOfOrNull(ExpectedRevisionTag::parse)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this revision displaces [other] as the channel's live canvas under the relay's
|
||||||
|
* `created_at DESC, id ASC` read order: a newer `created_at` wins, and a same-second tie goes to
|
||||||
|
* the lexicographically smallest id (not to whichever arrived last).
|
||||||
|
*/
|
||||||
|
fun isNewerHeadThan(other: Event?): Boolean = isNewerHead(createdAt, id, other?.createdAt, other?.id)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val KIND = 40100
|
const val KIND = 40100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The furthest a canvas head may sit in the future before a writer refuses to ratchet past
|
||||||
|
* it (seconds). Mirrors `CANVAS_MAX_FUTURE_SKEW_SECS` in `buzz-sdk/src/builders.rs`: stamping
|
||||||
|
* `max(now, head + 1)` against a poisoned far-future head would drag every later write along.
|
||||||
|
*/
|
||||||
|
const val MAX_HEAD_FUTURE_SKEW_SECS = 60L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far in the future the relay accepts a canvas `created_at` (seconds) —
|
||||||
|
* `CANVAS_MAX_INGEST_FUTURE_SECS` in `buzz-relay/src/handlers/ingest.rs`. A write stamped
|
||||||
|
* by [writeCreatedAt] never exceeds `now + MAX_HEAD_FUTURE_SKEW_SECS + 1`, well inside it.
|
||||||
|
*/
|
||||||
|
const val RELAY_MAX_FUTURE_SECS = 300L
|
||||||
|
|
||||||
|
/** OK-message prefix the relay uses when an `expected-revision` precondition fails. */
|
||||||
|
const val CONFLICT_PREFIX = "conflict:"
|
||||||
|
|
||||||
fun build(
|
fun build(
|
||||||
channelId: String,
|
channelId: String,
|
||||||
markdown: String,
|
markdown: String,
|
||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
expectedRevision: String? = null,
|
||||||
initializer: TagArrayBuilder<CanvasEvent>.() -> Unit = {},
|
initializer: TagArrayBuilder<CanvasEvent>.() -> Unit = {},
|
||||||
) = eventTemplate<CanvasEvent>(KIND, markdown, createdAt) {
|
) = eventTemplate<CanvasEvent>(KIND, markdown, createdAt) {
|
||||||
channel(channelId)
|
channel(channelId)
|
||||||
|
expectedRevision?.let { addUnique(ExpectedRevisionTag.assemble(it)) }
|
||||||
initializer()
|
initializer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writer discipline for a canvas write composed against a head stamped [headCreatedAt]:
|
||||||
|
* `max(now, headCreatedAt + 1)`, so the write sorts strictly ahead of that head. Returns
|
||||||
|
* null — refuse the write — when the head sits more than [MAX_HEAD_FUTURE_SKEW_SECS] in the
|
||||||
|
* future. With no head ([headCreatedAt] null) it is just [now].
|
||||||
|
*
|
||||||
|
* Mirrors `canvas_write_created_at` in `buzz-sdk/src/builders.rs`.
|
||||||
|
*/
|
||||||
|
fun writeCreatedAt(
|
||||||
|
headCreatedAt: Long?,
|
||||||
|
now: Long = TimeUtils.now(),
|
||||||
|
): Long? {
|
||||||
|
if (headCreatedAt == null) return now
|
||||||
|
if (headCreatedAt > now + MAX_HEAD_FUTURE_SKEW_SECS) return null
|
||||||
|
return maxOf(now, headCreatedAt + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `created_at DESC, id ASC` head selection: true when revision ([createdAt], [id]) displaces
|
||||||
|
* the current head ([headCreatedAt], [headId]). No head at all is always displaced; the same
|
||||||
|
* revision never displaces itself.
|
||||||
|
*/
|
||||||
|
fun isNewerHead(
|
||||||
|
createdAt: Long,
|
||||||
|
id: String,
|
||||||
|
headCreatedAt: Long?,
|
||||||
|
headId: String?,
|
||||||
|
): Boolean {
|
||||||
|
if (headCreatedAt == null || headId == null) return true
|
||||||
|
if (createdAt != headCreatedAt) return createdAt > headCreatedAt
|
||||||
|
return id < headId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a relay OK message reports a failed `expected-revision` compare-and-swap. */
|
||||||
|
fun isConflict(okMessage: String?): Boolean = okMessage?.startsWith(CONFLICT_PREFIX) == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -36,6 +36,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
* replaceable range `10002`, which was wrong). Channel-scoped via the `h` tag; the
|
* replaceable range `10002`, which was wrong). Channel-scoped via the `h` tag; the
|
||||||
* text lives in [content], optionally carrying `p` mentions and a `broadcast` flag.
|
* text lives in [content], optionally carrying `p` mentions and a `broadcast` flag.
|
||||||
* See `KIND_STREAM_MESSAGE_V2` in Buzz's `buzz-core/src/kind.rs`.
|
* See `KIND_STREAM_MESSAGE_V2` in Buzz's `buzz-core/src/kind.rs`.
|
||||||
|
*
|
||||||
|
* **Read-only in practice.** Buzz's own clients (and Amethyst) now write channel messages as kind 9
|
||||||
|
* in the same tag shape ([BuzzChatMessage]); this class stays so older 40002 messages still parse
|
||||||
|
* and render. [build] is kept for tests and tooling.
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
class StreamMessageV2Event(
|
class StreamMessageV2Event(
|
||||||
|
|||||||
+12
-2
@@ -35,8 +35,10 @@ import kotlinx.serialization.json.Json
|
|||||||
* present; [actor] is present on every variant the relay emits today, and the rest are
|
* present; [actor] is present on every variant the relay emits today, and the rest are
|
||||||
* variant-specific. Unknown keys are ignored for forward compatibility, so a relay that
|
* variant-specific. Unknown keys are ignored for forward compatibility, so a relay that
|
||||||
* grows a new field or a new [type] degrades to a plain line instead of failing to parse.
|
* grows a new field or a new [type] degrades to a plain line instead of failing to parse.
|
||||||
|
* Two relay-internal variants carry no [actor] at all: [ADMIN_KICK] and [CHANNEL_AUTO_ARCHIVED].
|
||||||
*
|
*
|
||||||
* The complete vocabulary, read off `side_effects.rs` / `command_executor.rs`:
|
* The complete vocabulary, read off `side_effects.rs` / `command_executor.rs` /
|
||||||
|
* `admin_outbox_worker.rs` / `main.rs`:
|
||||||
*
|
*
|
||||||
* | [type] | carries | emitted when |
|
* | [type] | carries | emitted when |
|
||||||
* |--|--|--|
|
* |--|--|--|
|
||||||
@@ -51,6 +53,8 @@ import kotlinx.serialization.json.Json
|
|||||||
* | [CHANNEL_CREATED] / [CHANNEL_DELETED] | [actor] | channel lifecycle |
|
* | [CHANNEL_CREATED] / [CHANNEL_DELETED] | [actor] | channel lifecycle |
|
||||||
* | [MESSAGE_DELETED] | [actor], [targetEventId], optional [actionId] / [reasonCode] / [publicReason] | a message was deleted (moderation tombstone) |
|
* | [MESSAGE_DELETED] | [actor], [targetEventId], optional [actionId] / [reasonCode] / [publicReason] | a message was deleted (moderation tombstone) |
|
||||||
* | [DM_CREATED] | [actor], [participants] | a DM channel was opened |
|
* | [DM_CREATED] | [actor], [participants] | a DM channel was opened |
|
||||||
|
* | [ADMIN_KICK] | [target], [actionId] — **no** [actor] | a relay/workspace administrator removed [target] (delivered by the admin outbox worker) |
|
||||||
|
* | [CHANNEL_AUTO_ARCHIVED] | nothing | the ephemeral-channel reaper archived an idle channel |
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SystemMessagePayload(
|
data class SystemMessagePayload(
|
||||||
@@ -80,7 +84,7 @@ data class SystemMessagePayload(
|
|||||||
*/
|
*/
|
||||||
fun subject(): String? =
|
fun subject(): String? =
|
||||||
when (type) {
|
when (type) {
|
||||||
MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED -> target ?: actor
|
MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED, ADMIN_KICK -> target ?: actor
|
||||||
else -> actor
|
else -> actor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +112,12 @@ data class SystemMessagePayload(
|
|||||||
const val MESSAGE_DELETED = "message_deleted"
|
const val MESSAGE_DELETED = "message_deleted"
|
||||||
const val DM_CREATED = "dm_created"
|
const val DM_CREATED = "dm_created"
|
||||||
|
|
||||||
|
/** An administrator removed [target]; relay-authored with no [actor] (`admin_outbox_worker.rs`). */
|
||||||
|
const val ADMIN_KICK = "admin_kick"
|
||||||
|
|
||||||
|
/** The relay's ephemeral-channel reaper archived the channel; carries no [actor] (`main.rs`). */
|
||||||
|
const val CHANNEL_AUTO_ARCHIVED = "channel_auto_archived"
|
||||||
|
|
||||||
/** Searchable, anyone can join. */
|
/** Searchable, anyone can join. */
|
||||||
const val VISIBILITY_OPEN = "open"
|
const val VISIBILITY_OPEN = "open"
|
||||||
|
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.buzz.stream.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.Hex
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The optional compare-and-swap precondition on a Buzz canvas write (`kind:40100`):
|
||||||
|
* `["expected-revision", "none" | <64-hex event id>]`.
|
||||||
|
*
|
||||||
|
* A 64-hex id names the canvas head the write was composed against; the literal [NONE] asserts
|
||||||
|
* that no head exists yet. The relay reads the live head under a lock and rejects a mismatch with
|
||||||
|
* a `conflict:` OK message before storing anything; a canvas write without the tag is an
|
||||||
|
* unconditional append.
|
||||||
|
*
|
||||||
|
* Mirrors `build_set_canvas` in Buzz's `buzz-sdk/src/builders.rs` and
|
||||||
|
* `parse_canvas_expected_revision` in `buzz-relay/src/handlers/ingest.rs`: the tag has exactly one
|
||||||
|
* value, and the relay rejects (`invalid:`) any other shape, so [parse] only accepts that one.
|
||||||
|
*/
|
||||||
|
class ExpectedRevisionTag {
|
||||||
|
companion object {
|
||||||
|
const val TAG_NAME = "expected-revision"
|
||||||
|
|
||||||
|
/** The sentinel value asserting the channel has no canvas head yet. */
|
||||||
|
const val NONE = "none"
|
||||||
|
|
||||||
|
/** True for [NONE] or a 64-character hex event id — the only values the relay accepts. */
|
||||||
|
fun isValidValue(value: String): Boolean = value == NONE || (value.length == 64 && Hex.isHex(value))
|
||||||
|
|
||||||
|
fun parse(tag: Array<String>): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag.size == 2) { return null }
|
||||||
|
ensure(isValidValue(tag[1])) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(expectedRevision: String): Array<String> {
|
||||||
|
require(isValidValue(expectedRevision)) {
|
||||||
|
"expected-revision must be the literal \"none\" or a 64-character hex event id (got \"$expectedRevision\")"
|
||||||
|
}
|
||||||
|
return arrayOf(TAG_NAME, expectedRevision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user