feat(commons): Buzz workspaces as NIP-29 groups in the Buzz dialect

Surfaces block/buzz workspace channels inside the existing NIP-29 relay-group
experience — one group model, dialect-aware rendering, zero impact on vanilla
groups (kind-filtered feeds can never receive Buzz kinds by accident).

- BuzzRelayDialect (commons): per-relay capability registry. Event-shape
  detection — the first Buzz-only kind consumed from a relay marks it; vanilla
  relays never serve those kinds, so no false positives. NIP-11 marking can be
  layered on later.
- BuzzWorkspaceChannel (commons): sibling of RelayGroupChannel (now `open`)
  holding Buzz-only channel state: the kind-40003 edit overlay (never render
  superseded text as current) and the newest kind-40100 canvas. Kind-9 chat and
  kind-40002 stream messages share ONE timeline per group so mixed-dialect
  conversations stay whole.
- LocalCache: consumes every registered Buzz kind (previously all fell into the
  "Event Not Supported" branch). Timeline kinds attach to the group's channel,
  materialized dialect-aware with an in-place upgrade (note migration) when the
  dialect is discovered after the channel was first created as plain NIP-29.
  Addressables store replaceably; the rest store as queryable regular events;
  ephemeral signals (typing 20002, observer 24200, huddle reaction 24810,
  pairing 24134) mark the dialect but are deliberately not persisted.
- RelayGroupFilterBuilders: group-chat REQs widen their timeline kind set with
  40002/40003/40008/40099 only for marked relays; vanilla NIP-29 REQs unchanged.

Tests cover dialect detection + materialization, the plain-channel upgrade with
timeline migration, newest-edit-wins overlay ordering, and that filter builders
extend kinds only on marked relays. Full amethyst unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-21 20:58:11 +00:00
parent d7125b8b08
commit 7903834efd
8 changed files with 742 additions and 5 deletions
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceChannel
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
@@ -50,6 +52,77 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.erReminders.EventReminderEvent
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentitiesListEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentityEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchivedIdentityEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.managedAgents.ManagedAgentEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationBanEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
import com.vitorpamplona.quartz.buzz.plPushLease.PushLeaseEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminChangeRoleEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageBookmarkedEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessagePinnedEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalDeniedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalGrantedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCancelledEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
@@ -734,7 +807,31 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getOrCreateGeohashChannel(geohash: String): GeohashChatChannel = geohashChannels.getOrCreate(geohash) { GeohashChatChannel(geohash) }
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) }
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel =
relayGroupChannels.getOrCreate(key) {
// A relay marked as Buzz materializes its groups as workspace channels so
// Buzz-only state (edit overlays, canvas) has a home; everything else stays
// a plain NIP-29 group. See BuzzRelayDialect.
if (BuzzRelayDialect.isBuzz(key.relayUrl)) BuzzWorkspaceChannel(key) else RelayGroupChannel(key)
}
/**
* Returns the group's channel as a [BuzzWorkspaceChannel], upgrading a plain
* [RelayGroupChannel] in place when the group's relay turns out to speak the Buzz
* dialect after the channel was first materialized (e.g. its 39000 metadata arrived
* before the first Buzz-kind event). The upgrade migrates the attached timeline
* notes; per-relay pagination cursors reset, which only costs a re-page.
*/
private fun getOrUpgradeBuzzChannel(key: GroupId): BuzzWorkspaceChannel {
val existing = getOrCreateRelayGroupChannel(key)
if (existing is BuzzWorkspaceChannel) return existing
val upgraded = BuzzWorkspaceChannel(key)
existing.notes.forEach { _, note -> upgraded.addNote(note, null) }
existing.relays().forEach { upgraded.addRelay(it) }
relayGroupChannels.put(key, upgraded)
return upgraded
}
fun getConcordChannelIfExists(key: ConcordChannelId): ConcordChannel? = concordChannels.get(key)
@@ -1986,6 +2083,75 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
/**
* Consume + channel-attach for Buzz workspace timeline kinds (stream messages,
* diffs, system rows, forum posts, job cards, huddle lifecycle). These kinds only
* exist on `block/buzz` relays, so their arrival IS the dialect detection: the
* serving relay is marked in [BuzzRelayDialect] and the group's channel is
* materialized (or upgraded) as a [BuzzWorkspaceChannel] before the shared NIP-29
* attach routes the note into the same one-timeline-per-group channel kind-9 chat
* uses. Mixed vanilla/Buzz conversations therefore stay whole.
*/
private fun consumeBuzzTimelineEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
if (relay != null) {
BuzzRelayDialect.mark(relay)
event.groupId()?.let { getOrUpgradeBuzzChannel(GroupId(it, relay)) }
}
attachToRelayGroupIfScoped(event, relay)
}
/** Store-only consume for Buzz kinds that carry no channel timeline row; still marks the dialect. */
private fun consumeBuzzRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
if (relay != null) BuzzRelayDialect.mark(relay)
}
/** The group's [BuzzWorkspaceChannel] for a consumed Buzz event, when resolvable. */
private fun buzzChannelFor(
event: Event,
relay: NormalizedRelayUrl?,
): BuzzWorkspaceChannel? {
val groupId = event.groupId() ?: return null
if (relay == null) return null
return getOrUpgradeBuzzChannel(GroupId(groupId, relay))
}
private fun consume(
event: StreamMessageEditEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeBuzzTimelineEvent(event, relay, wasVerified).also {
// A 40003 replaces an earlier message's content: track the overlay so the
// UI never renders superseded text as current.
val target = event.editedMessage() ?: return@also
val editNote = getOrCreateNote(event.id)
if (editNote.event != null) {
buzzChannelFor(event, relay)?.addEdit(target, editNote)
}
}
private fun consume(
event: CanvasEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeBuzzTimelineEvent(event, relay, wasVerified).also {
val note = getOrCreateNote(event.id)
if (note.event != null) {
buzzChannelFor(event, relay)?.updateCanvas(note)
}
}
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
@@ -4407,6 +4573,102 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
// ------------------------------------------------------------------
// Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29).
// Timeline kinds attach into the group's BuzzWorkspaceChannel; the
// rest are stored for query/state. Kinds 9041/20001/39005/49001 are
// absent on purpose: their numbers belong to GoalEvent,
// GeohashPresenceEvent, GroupPinnedEvent and a non-wire audit kind.
// ------------------------------------------------------------------
is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is StreamMessageEditEvent -> consume(event, relay, wasVerified)
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is CanvasEvent -> consume(event, relay, wasVerified)
is ForumPostEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is ForumCommentEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is ForumVoteEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
// Buzz addressable/replaceable state.
is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is DmVisibilityEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
// Buzz store-only regular kinds (queryable state; no timeline row yet).
is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberRemovedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
// Buzz ephemeral signals: transient by definition (20000-29999); mark
// the dialect but do not pollute the note store with typing/telemetry.
is TypingIndicatorEvent -> {
relay?.let { BuzzRelayDialect.mark(it) }
false
}
is ObserverFrameEvent -> {
relay?.let { BuzzRelayDialect.mark(it) }
false
}
is HuddleReactionEvent -> {
relay?.let { BuzzRelayDialect.mark(it) }
false
}
is PairingEvent -> false
is PollEvent -> {
consumeRegularEvent(event, relay, wasVerified).also {
attachToRelayGroupIfScoped(event, relay)
@@ -20,6 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -85,6 +90,28 @@ val RELAY_GROUP_STATE_KINDS = RELAY_GROUP_METADATA_KINDS + RELAY_GROUP_PIN_KINDS
/** Timeline kinds shown in a group's chat — chat messages and polls. */
val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* Extra timeline kinds a `block/buzz` workspace relay serves in the same `h`-scoped
* channels: stream messages v2 (40002), edits (40003), diffs (40008) and system rows
* (40099). Only requested from relays [BuzzRelayDialect] has marked harmless
* elsewhere, but there is no reason to widen every vanilla NIP-29 REQ.
*/
val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
listOf(
StreamMessageV2Event.KIND,
StreamMessageEditEvent.KIND,
StreamMessageDiffEvent.KIND,
SystemMessageEvent.KIND,
)
/** The timeline kinds to request from [relay]: the NIP-29 set, plus Buzz kinds on Buzz relays. */
fun relayGroupTimelineKinds(relay: NormalizedRelayUrl): List<Int> =
if (BuzzRelayDialect.isBuzz(relay)) {
RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
} else {
RELAY_GROUP_TIMELINE_KINDS
}
/** Forum-thread kinds shown in a group's Threads tab. */
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
@@ -146,7 +173,7 @@ fun buildRelayGroupJoinedChatTailFilters(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = relayGroupTimelineKinds(relay),
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
since = sinceEpoch,
),
@@ -162,7 +189,7 @@ fun buildRelayGroupOpenChatTailFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = relayGroupTimelineKinds(groupId.relayUrl),
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),
@@ -185,7 +212,7 @@ fun buildRelayGroupHistoryFilters(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = relayGroupTimelineKinds(relay),
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
until = until,
limit = limit,
@@ -0,0 +1,167 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import android.os.Looper
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceChannel
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.UUID
/**
* The Buzz dialect of NIP-29 in `LocalCache`: dialect detection from event shape,
* `BuzzWorkspaceChannel` materialization (including the upgrade of a channel that was
* first created as a plain [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]),
* timeline attachment, and the 40003 edit overlay.
*/
class BuzzWorkspaceChannelTest {
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
private val vanillaRelay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example.com/")!!
private val signer = NostrSignerInternal(KeyPair())
@Before
fun setup() {
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper,
// where null == null reads as "main". Distinct mocks make it a worker thread.
mockkStatic(Looper::class)
every { Looper.myLooper() } returns mockk<Looper>()
every { Looper.getMainLooper() } returns mockk<Looper>()
BuzzRelayDialect.clearForTesting()
}
@After
fun tearDown() {
unmockkStatic(Looper::class)
BuzzRelayDialect.clearForTesting()
}
private fun newChannelId() = UUID.randomUUID().toString()
private suspend fun streamMessage(
channelId: String,
text: String,
) = signer.sign(StreamMessageV2Event.build(channelId, text))
@Test
fun buzzKindMarksDialectAndMaterializesWorkspaceChannel() =
runBlocking {
val channelId = newChannelId()
val msg = streamMessage(channelId, "hello workspace")
assertFalse(BuzzRelayDialect.isBuzz(buzzRelay))
LocalCache.checkDeletionAndConsume(msg, buzzRelay, false)
assertTrue("consuming a 40002 must mark the serving relay as Buzz", BuzzRelayDialect.isBuzz(buzzRelay))
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))
assertNotNull("the group channel must exist after a 40002 lands", channel)
assertTrue("a Buzz relay's group must be a BuzzWorkspaceChannel", channel is BuzzWorkspaceChannel)
assertTrue("the message must be attached to the channel timeline", channel!!.notes.containsKey(msg.id))
}
@Test
fun vanillaRelayStaysPlainRelayGroup() {
val key = GroupId("plain-group", vanillaRelay)
val channel = LocalCache.getOrCreateRelayGroupChannel(key)
assertFalse(
"a group on an unmarked relay must NOT materialize as a Buzz channel",
channel is BuzzWorkspaceChannel,
)
}
@Test
fun plainChannelUpgradesWhenDialectIsDiscoveredLater() =
runBlocking {
val channelId = newChannelId()
val key = GroupId(channelId, buzzRelay)
// Arrival order: the group materializes plain (e.g. via its 39000 metadata)
// BEFORE any Buzz kind reveals the dialect...
val plain = LocalCache.getOrCreateRelayGroupChannel(key)
assertFalse(plain is BuzzWorkspaceChannel)
// A kind-9 note is already attached to the plain channel.
val vanillaMsg =
signer.sign(
ChatEvent.build("kind 9 from a vanilla client") {
add(arrayOf("h", channelId))
},
)
LocalCache.checkDeletionAndConsume(vanillaMsg, buzzRelay, false)
// ...then the first 40002 arrives and upgrades it, migrating the timeline.
val buzzMsg = streamMessage(channelId, "kind 40002 from a Buzz client")
LocalCache.checkDeletionAndConsume(buzzMsg, buzzRelay, false)
val upgraded = LocalCache.getRelayGroupChannelIfExists(key)
assertTrue("channel must be upgraded in place", upgraded is BuzzWorkspaceChannel)
assertTrue("pre-upgrade kind-9 note must be migrated", upgraded!!.notes.containsKey(vanillaMsg.id))
assertTrue("the Buzz message shares the same timeline", upgraded.notes.containsKey(buzzMsg.id))
}
@Test
fun editOverlayTracksNewestEdit() =
runBlocking {
val channelId = newChannelId()
val original = streamMessage(channelId, "teh typo")
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
val edit1 =
signer.sign(
StreamMessageEditEvent.build(channelId, original.id, "the typo", createdAt = original.createdAt + 10),
)
val edit2 =
signer.sign(
StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 20),
)
LocalCache.checkDeletionAndConsume(edit2, buzzRelay, false)
LocalCache.checkDeletionAndConsume(edit1, buzzRelay, false)
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay)) as BuzzWorkspaceChannel
assertEquals(
"the newest edit wins regardless of arrival order",
"the fix",
channel.effectiveContentFor(original.id),
)
assertEquals(edit2.id, channel.editFor(original.id)?.idHex)
}
}
@@ -0,0 +1,79 @@
/*
* 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.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* The group-chat REQ builders widen their timeline kind set with the Buzz stream kinds
* only for relays [BuzzRelayDialect] has marked vanilla NIP-29 REQs stay untouched.
*/
class BuzzTimelineKindsTest {
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
private val vanillaRelay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example.com/")!!
@Before
fun setup() {
BuzzRelayDialect.clearForTesting()
}
@After
fun tearDown() {
BuzzRelayDialect.clearForTesting()
}
@Test
fun vanillaRelayKeepsThePlainNip29KindSet() {
assertEquals(RELAY_GROUP_TIMELINE_KINDS, relayGroupTimelineKinds(vanillaRelay))
}
@Test
fun buzzRelayGetsTheExtendedKindSet() {
BuzzRelayDialect.mark(buzzRelay)
val kinds = relayGroupTimelineKinds(buzzRelay)
assertTrue(kinds.containsAll(RELAY_GROUP_TIMELINE_KINDS))
assertTrue(kinds.containsAll(BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS))
// The vanilla relay is unaffected by the buzz mark on another relay.
assertEquals(RELAY_GROUP_TIMELINE_KINDS, relayGroupTimelineKinds(vanillaRelay))
}
@Test
fun openChatTailFilterCarriesBuzzKindsOnlyOnBuzzRelays() {
BuzzRelayDialect.mark(buzzRelay)
val buzzFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", buzzRelay), sinceEpoch = 0L)
val vanillaFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", vanillaRelay), sinceEpoch = 0L)
assertTrue(buzzFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND))
assertFalse(vanillaFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND))
}
}
@@ -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.amethyst.commons.model.buzz
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Tracks which relays speak the **Buzz dialect** of NIP-29 `block/buzz` workspace
* relays whose channel timelines carry Buzz-custom kinds (stream messages 40002,
* edits 40003, diffs 40008, system rows 40099, canvas 40100, forum 45001/45003,
* agent jobs 43001-43006, huddle cards 48100-48103) alongside plain kind-9 chat.
*
* Detection sources, in order of arrival:
* 1. **Event shape** (implemented): the first Buzz-only channel kind consumed from a
* relay marks it (`LocalCache` calls [mark] before materializing the channel). A
* vanilla NIP-29 relay never serves those kinds, so there are no false positives.
* 2. **NIP-11** (future): a relay announcing Buzz in its info document can be marked
* at connect time, before any event arrives.
*
* The registry decides which channel type `LocalCache` materializes for a group
* (`BuzzWorkspaceChannel` vs plain `RelayGroupChannel`) and which timeline kinds the
* group-chat REQ builders put on the wire for that relay. Marks only ever ADD
* capability: un-marking is never needed because a marked relay provably served a
* Buzz kind, and extra requested kinds are harmless on any relay.
*
* Like `LocalCache`, this is a process-wide singleton (one copy per Android process).
*/
object BuzzRelayDialect {
private val buzzRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/** Relays known to speak the Buzz dialect; UI can collect this for affordances. */
val flow: StateFlow<Set<NormalizedRelayUrl>> = buzzRelays
fun isBuzz(relay: NormalizedRelayUrl): Boolean = relay in buzzRelays.value
/** Marks [relay] as Buzz. Returns true when this call changed the state. */
fun mark(relay: NormalizedRelayUrl): Boolean {
while (true) {
val current = buzzRelays.value
if (relay in current) return false
if (buzzRelays.compareAndSet(current, current + relay)) return true
}
}
/** Test-only: clears all marks so unit tests don't leak state into each other. */
fun clearForTesting() {
buzzRelays.value = emptySet()
}
}
@@ -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 androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* A `block/buzz` workspace channel: a [RelayGroupChannel] on a relay that speaks the
* Buzz dialect of NIP-29 (see [BuzzRelayDialect]).
*
* Buzz channels keep the whole NIP-29 machinery the `h`-scoped [groupId], relay-signed
* 39000-39003 state, membership and add channel content the vanilla renderer must not
* ignore, because skipping it misrepresents the conversation:
*
* - **kind 40003 message edits**: an overlay replacing an earlier message's content.
* Rendering the original without the edit shows *stale text as current*, so the
* overlay is tracked here ([editFor]) rather than left as a loose note.
* - **kind 40100 canvas**: the channel's collaborative document; only the newest
* matters ([canvasNote]).
* - Stream messages (40002), diffs (40008), system rows (40099), forum posts and agent
* job cards flow through the base timeline exactly like kind-9 chat they need no
* extra state here, just kind-aware rendering.
*
* One timeline per group: kind-9 chat from vanilla NIP-29 clients and 40002 from Buzz
* clients land in the SAME channel, so mixed-dialect conversations stay whole.
*/
@Stable
class BuzzWorkspaceChannel(
groupId: GroupId,
) : RelayGroupChannel(groupId) {
/**
* Newest 40003 edit per target message id. Keyed by the edited message's event id;
* the value is the edit note (whose content is the replacement text).
*/
private val editsByTarget = LargeCache<HexKey, Note>()
private val editVersion = MutableStateFlow(0)
/** Bumps when any edit overlay changes, so rows can re-read [editFor]. */
val editUpdates: StateFlow<Int> = editVersion
/** The newest canvas (kind 40100) note for this channel, or null when none seen. */
var canvasNote: Note? = null
private set
/**
* Records a 40003 edit note. Keeps only the newest edit per target (Buzz's own UI
* applies last-write-wins by `created_at`). The note may still be loading when the
* cache attaches it; callers pass the already-loaded note.
*/
fun addEdit(
targetId: HexKey,
editNote: Note,
) {
val current = editsByTarget.get(targetId)
if (current == null || (editNote.createdAt() ?: 0L) > (current.createdAt() ?: 0L)) {
editsByTarget.put(targetId, editNote)
editVersion.value++
}
}
/** The newest edit note overlaying [targetId], or null when the message is unedited. */
fun editFor(targetId: HexKey): Note? = editsByTarget.get(targetId)
/** The effective display content for a message: its newest edit's text, or null when unedited. */
fun effectiveContentFor(targetId: HexKey): String? = editsByTarget.get(targetId)?.event?.content
fun updateCanvas(note: Note) {
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
canvasNote = note
}
}
companion object {
/** Marker so generic code can ask "is this a Buzz edit?" without importing quartz kinds. */
const val EDIT_KIND = StreamMessageEditEvent.KIND
}
}
@@ -52,7 +52,7 @@ import kotlinx.coroutines.flow.StateFlow
* host relay, so every read and write is pinned there via [relays].
*/
@Stable
class RelayGroupChannel(
open class RelayGroupChannel(
val groupId: GroupId,
) : Channel() {
/**
@@ -116,6 +116,35 @@ Interop lessons encoded in the test:
treated as absent and channel-scoped kinds are rejected.
- The channel must exist (9007) before channel-scoped kinds are accepted.
## Client integration (commons + amethyst)
Buzz workspaces surface in Amethyst as **NIP-29 groups in the Buzz dialect** — one
group model, two dialect renderers, and no impact on vanilla groups (feeds are
kind-filtered, so they can never receive Buzz kinds by accident):
- `commons/.../model/buzz/BuzzRelayDialect.kt` — per-relay capability registry.
Detection is event-shape based: the first Buzz-only kind consumed from a relay marks
it (no false positives — vanilla relays never serve those kinds). NIP-11 marking can
be added later for pre-connect detection.
- `commons/.../model/buzz/BuzzWorkspaceChannel.kt` — sibling of `RelayGroupChannel`
(which is now `open`). Holds Buzz-only channel state: the kind-40003 **edit overlay**
(never render superseded text as current) and the newest kind-40100 **canvas**.
Kind-9 chat and kind-40002 stream messages share ONE timeline per group, so
mixed-dialect conversations stay whole.
- `amethyst LocalCache` consumes every registered Buzz kind (they previously fell into
the "Event Not Supported" branch): timeline kinds attach to the group's
`BuzzWorkspaceChannel` (materialized dialect-aware, with an in-place upgrade when the
dialect is discovered after the channel was first created), addressables store
replaceably, the rest store as queryable regular events, and ephemeral signals
(typing 20002, observer 24200, huddle reactions 24810, pairing 24134) mark the
dialect but are deliberately not persisted.
- The group-chat REQ builders (`RelayGroupFilterBuilders`) widen their timeline kind
set with 40002/40003/40008/40099 **only for marked relays**, so vanilla NIP-29 REQs
are untouched.
Not yet wired: kind-aware rendering of 40002/40099/diff rows in the chat UI, the
composer switch (write 40002 on Buzz relays), and NIP-42-at-connect workspace sessions.
## Owner Attestation (NIP-OA) — implemented
The primitive that makes "agents as first-class members" work without enrolling every