mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
24 Commits
35e61e1d98
...
claude/buz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12aa9fe954 | ||
|
|
54364731a6 | ||
|
|
a0d50c69f6 | ||
|
|
5d9efc800a | ||
|
|
f869834e9d | ||
|
|
fb7bdf3057 | ||
|
|
f31e7a4c0b | ||
|
|
4a4baf6a23 | ||
|
|
bce544e8c3 | ||
|
|
0ea5d0e876 | ||
|
|
5137b92046 | ||
|
|
2124409025 | ||
|
|
1b76dd250e | ||
|
|
201aafb5bf | ||
|
|
81fa81612b | ||
|
|
c79b795477 | ||
|
|
6bc16314e8 | ||
|
|
c85f51c40a | ||
|
|
54ecae50af | ||
|
|
7903834efd | ||
|
|
d7125b8b08 | ||
|
|
f5d22ce720 | ||
|
|
1757ef4513 | ||
|
|
a54c808763 |
@@ -153,6 +153,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
@@ -2903,6 +2904,17 @@ class Account(
|
||||
follow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
|
||||
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
|
||||
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
|
||||
*/
|
||||
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
|
||||
if (!isWriteable()) return
|
||||
val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
|
||||
client.publish(signed, setOf(channel.groupId.relayUrl))
|
||||
}
|
||||
|
||||
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
|
||||
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = LeaveRequestEvent.build(channel.groupId.id)
|
||||
|
||||
@@ -28,6 +28,9 @@ 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.BuzzTypingState
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
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 +53,82 @@ 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.audit.AuditEntryEvent
|
||||
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.stream.sidecars.ChannelSummaryEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
|
||||
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
||||
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
|
||||
@@ -1257,6 +1336,16 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
(eTagTargets + qTagTargets).mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
|
||||
is StreamMessageV2Event -> {
|
||||
// Buzz threads 40002s with marked root/reply e-tags (thread_tags in
|
||||
// buzz-sdk). Link both so inbound replies render their quote bubble —
|
||||
// we emit these markers on send, so we must also read them.
|
||||
listOfNotNull(
|
||||
event.tags.buzzThreadRoot(),
|
||||
event.tags.buzzThreadReply(),
|
||||
).distinct().mapNotNull { checkGetOrCreateNote(it) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1986,6 +2075,82 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return new
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
private fun markBuzzIfVerified(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
) {
|
||||
if (relay != null && getNoteIfExists(event.id)?.event != null) {
|
||||
BuzzRelayDialect.mark(relay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (verified) arrival IS the dialect
|
||||
* detection. Attachment goes through the SAME shared NIP-29 path kind-9 chat uses
|
||||
* — including its stray-redirect protection — so mixed vanilla/Buzz conversations
|
||||
* share one timeline and non-host strays never mint phantom channels.
|
||||
*/
|
||||
private fun consumeBuzzTimelineEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
markBuzzIfVerified(event, relay)
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
}
|
||||
|
||||
/** Store-only consume for Buzz kinds that carry no channel timeline row. */
|
||||
private fun consumeBuzzRegularEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
markBuzzIfVerified(event, relay)
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: StreamMessageEditEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
// Buzz's own timeline set excludes 40003: an edit is an OVERLAY replacing an
|
||||
// earlier message's content, never a row of its own. Store it and record the
|
||||
// overlay (keyed by the channel's UUID, so own sends with no provenance relay
|
||||
// land too), but do NOT attach it to the timeline.
|
||||
consumeBuzzRegularEvent(event, relay, wasVerified).also {
|
||||
val target = event.editedMessage() ?: return@also
|
||||
val channelId = event.channel() ?: return@also
|
||||
val editNote = getOrCreateNote(event.id)
|
||||
if (editNote.event != null) {
|
||||
BuzzWorkspaceStates.getOrCreate(channelId).addEdit(target, editNote)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: CanvasEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean =
|
||||
// The canvas is the channel's single living document, not a chat row: track
|
||||
// only the newest revision as overlay state, never attach it to the timeline.
|
||||
consumeBuzzRegularEvent(event, relay, wasVerified).also {
|
||||
val channelId = event.channel() ?: return@also
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event != null) {
|
||||
BuzzWorkspaceStates.getOrCreate(channelId).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
|
||||
@@ -3040,6 +3205,13 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS)
|
||||
}
|
||||
|
||||
// A Buzz workspace's edit/canvas overlay is keyed off the channel id, outside
|
||||
// `notes`, so the top-N reap never touches it. Drop overlay entries whose target
|
||||
// message was just pruned, else they pin the edit note + author forever.
|
||||
if (channel is RelayGroupChannel) {
|
||||
BuzzWorkspaceStates.getIfExists(channel.groupId.id)?.pruneEdits(channel.notes.keys())
|
||||
}
|
||||
|
||||
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
|
||||
@@ -4407,6 +4579,107 @@ 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 relay-signed sidecars and audit projections: store-only, queryable.
|
||||
is AuditEntryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is ChannelSummaryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
is PresenceSnapshotEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
|
||||
|
||||
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
|
||||
// pollute the note store, and do NOT mark the dialect from them (they are
|
||||
// never stored, so the verified-mark gate has nothing to check).
|
||||
is TypingIndicatorEvent -> {
|
||||
// Live "who is typing" side-effect only — record the heartbeat into the
|
||||
// process-wide typing state (the channel view collects it) and return
|
||||
// false so it never becomes a feed row. Own-typing is filtered in the UI.
|
||||
event.channelId()?.let { BuzzTypingState.record(it, event.pubKey, event.createdAt, TimeUtils.now()) }
|
||||
false
|
||||
}
|
||||
is ObserverFrameEvent -> false
|
||||
is HuddleReactionEvent -> false
|
||||
// Pairing (24134) is deliberately dialect-neutral: it flows during device
|
||||
// pairing before any workspace relationship is established.
|
||||
is PairingEvent -> false
|
||||
|
||||
is PollEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified).also {
|
||||
attachToRelayGroupIfScoped(event, relay)
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
@@ -135,7 +137,7 @@ class AuthCoordinator(
|
||||
// Remember why we granted this relay so the settings screen can explain it.
|
||||
account.relayAuthLedger.recordGrant(context)
|
||||
try {
|
||||
signed.add(account.signer.sign(authTemplate))
|
||||
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
|
||||
}
|
||||
@@ -169,6 +171,27 @@ class AuthCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
|
||||
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
|
||||
* appended — so the relay grants virtual membership to an un-enrolled agent key while
|
||||
* its owner stays a member. Otherwise returns [template] unchanged.
|
||||
*
|
||||
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
|
||||
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
|
||||
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
|
||||
* add an `auth` tag where one isn't wanted.
|
||||
*/
|
||||
private fun buzzAugmented(
|
||||
template: EventTemplate<RelayAuthEvent>,
|
||||
accountPubKey: HexKey,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
): EventTemplate<RelayAuthEvent> {
|
||||
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
|
||||
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
|
||||
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
|
||||
}
|
||||
|
||||
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
|
||||
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
|
||||
|
||||
|
||||
@@ -99,6 +99,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.Boo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentAttestationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
|
||||
@@ -453,9 +458,13 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BuzzWorkspaces> { BuzzWorkspacesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AgentConsole> { AgentConsoleScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AgentAttestation> { AgentAttestationScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.AgentPersonaEdit> { AgentPersonaEditScreen(it.slug, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
|
||||
@@ -752,6 +761,7 @@ fun BuildNavigation(
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.RelayGroupCreate> {
|
||||
RelayGroupCreateScreen(
|
||||
|
||||
@@ -69,6 +69,7 @@ enum class NavBarItem {
|
||||
PODCASTS,
|
||||
PUBLIC_CHATS,
|
||||
RELAY_GROUPS,
|
||||
BUZZ,
|
||||
CONCORD,
|
||||
GEOHASH_CHATS,
|
||||
FOLLOW_PACKS,
|
||||
@@ -344,6 +345,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.Forum,
|
||||
resolveRoute = { Route.RelayGroups },
|
||||
),
|
||||
NavBarItem.BUZZ to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.BUZZ,
|
||||
labelRes = R.string.buzz_workspaces_title,
|
||||
icon = MaterialSymbols.AutoAwesome,
|
||||
resolveRoute = { Route.BuzzWorkspaces },
|
||||
),
|
||||
NavBarItem.CONCORD to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.CONCORD,
|
||||
@@ -496,6 +504,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
listOf(
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.BUZZ,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
),
|
||||
|
||||
@@ -469,6 +469,14 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditNestsServers : Route()
|
||||
|
||||
@Serializable object AgentConsole : Route()
|
||||
|
||||
@Serializable object AgentAttestation : Route()
|
||||
|
||||
@Serializable data class AgentPersonaEdit(
|
||||
val slug: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
@@ -691,6 +699,10 @@ sealed class Route {
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class BuzzCanvas(
|
||||
val channelId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class RelayGroupCreate(
|
||||
val relayUrl: String,
|
||||
) : Route()
|
||||
@@ -702,6 +714,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object RelayGroups : Route()
|
||||
|
||||
@Serializable object BuzzWorkspaces : Route()
|
||||
|
||||
@Serializable object RelayGroupBrowse : Route()
|
||||
|
||||
// Concord Channels (encrypted communities). Addressed by community id + channel id
|
||||
|
||||
@@ -685,6 +685,11 @@ class AccountViewModel(
|
||||
account.sendConcordTyping(communityId, channelIdHex)
|
||||
}
|
||||
|
||||
fun sendBuzzTyping(channel: RelayGroupChannel) =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.sendBuzzTyping(channel)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class NoteComposeReportState(
|
||||
val isPostHidden: Boolean = false,
|
||||
|
||||
@@ -137,6 +137,10 @@ private fun PreloadFor(
|
||||
// this tab needs no extra preload.
|
||||
NavBarItem.RELAY_GROUPS -> Unit
|
||||
|
||||
// Buzz workspaces are relay groups on Buzz-dialect relays; they ride the same
|
||||
// always-on relay-group state subscription, so there's nothing extra to preload.
|
||||
NavBarItem.BUZZ -> Unit
|
||||
|
||||
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
|
||||
|
||||
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
/*
|
||||
* 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.buzz
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
|
||||
* ([OwnerAttestation]) authorizing an agent pubkey to publish under optional
|
||||
* [AttestationConditions]; the agent then attaches the resulting `auth` tag to its
|
||||
* events and the relay grants it virtual membership while the owner stays a member.
|
||||
*
|
||||
* This is entirely offline: the signature covers a hashed commitment string, not a
|
||||
* Nostr event, so it needs the owner's **raw private key** — a NIP-46 bunker or NIP-55
|
||||
* external signer cannot produce it. Read-only accounts see an explanation instead of
|
||||
* the form. Nothing is published; the signed tag is a credential the owner hands to the
|
||||
* agent operator out-of-band.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentAttestationScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val keyPair = accountViewModel.account.settings.keyPair
|
||||
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Attestations", nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Agent side: hold an attestation an owner gave you, so this account
|
||||
// authenticates to the owner's Buzz relays as a virtual member. Available to
|
||||
// any signer — holding a credential doesn't require the raw key.
|
||||
HoldAttestationSection(myPubkey = myPubkey)
|
||||
|
||||
// Owner side: issue an attestation for an agent key. Needs the raw private key.
|
||||
val privKey = keyPair.privKey
|
||||
if (privKey == null) {
|
||||
ReadOnlyKeyNotice()
|
||||
} else {
|
||||
AttestationForm(ownerKey = keyPair)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
|
||||
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
|
||||
* coordinator attaches it when this account AUTHs to a Buzz relay. In-memory only for
|
||||
* now — re-paste after an app restart.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldAttestationSection(myPubkey: String) {
|
||||
val held by BuzzHeldAttestations.flow.collectAsState()
|
||||
val mine = held[myPubkey]
|
||||
|
||||
var input by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Hold an attestation",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (mine != null) {
|
||||
Text(
|
||||
text = "Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Grants: " + mine.conditions.ifEmpty { "any kind, any time (unrestricted)" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
|
||||
Text("Remove")
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = {
|
||||
input = it
|
||||
error = null
|
||||
},
|
||||
label = { Text("auth tag JSON") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
error?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
when (val outcome = parseHeldAttestation(input, myPubkey)) {
|
||||
is HoldOutcome.Failure -> error = outcome.message
|
||||
is HoldOutcome.Success -> {
|
||||
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
|
||||
input = ""
|
||||
error = null
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = input.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Hold attestation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface HoldOutcome {
|
||||
data class Success(
|
||||
val attestation: OwnerAttestation,
|
||||
) : HoldOutcome
|
||||
|
||||
data class Failure(
|
||||
val message: String,
|
||||
) : HoldOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
|
||||
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
|
||||
* non-`auth` tag, or a signature that doesn't verify for this key.
|
||||
*/
|
||||
private fun parseHeldAttestation(
|
||||
input: String,
|
||||
myPubkey: String,
|
||||
): HoldOutcome {
|
||||
val tag =
|
||||
try {
|
||||
Json
|
||||
.parseToJsonElement(input.trim())
|
||||
.jsonArray
|
||||
.map { it.jsonPrimitive.content }
|
||||
.toTypedArray()
|
||||
} catch (e: Exception) {
|
||||
return HoldOutcome.Failure("Not a valid JSON tag array. Paste the [\"auth\", …] tag you were given.")
|
||||
}
|
||||
val attestation =
|
||||
OwnerAttestation.parse(tag)
|
||||
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
|
||||
if (!attestation.verify(myPubkey)) {
|
||||
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
|
||||
}
|
||||
return HoldOutcome.Success(attestation)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadOnlyKeyNotice() {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Local key required",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
"A NIP-OA attestation is a signature over a hashed commitment, not a Nostr event, " +
|
||||
"so it can only be produced by a signer that holds your raw private key. This " +
|
||||
"account uses a remote (NIP-46 bunker) or external (NIP-55) signer, which cannot " +
|
||||
"sign an attestation.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttestationForm(ownerKey: KeyPair) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var agentInput by remember { mutableStateOf("") }
|
||||
var kindInput by remember { mutableStateOf("") }
|
||||
var afterInput by remember { mutableStateOf("") }
|
||||
var beforeInput by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var result by remember { mutableStateOf<OwnerAttestation?>(null) }
|
||||
|
||||
Text(
|
||||
text =
|
||||
"Authorize an agent pubkey to publish in your workspace without enrolling its key. " +
|
||||
"The agent attaches the signed tag below to its events.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = agentInput,
|
||||
onValueChange = {
|
||||
agentInput = it
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Agent public key (npub or hex)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Conditions (optional) — leave blank for an unrestricted attestation.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = kindInput,
|
||||
onValueChange = {
|
||||
kindInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Restrict to kind (0–65535)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = afterInput,
|
||||
onValueChange = {
|
||||
afterInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Only events after (unix seconds)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = beforeInput,
|
||||
onValueChange = {
|
||||
beforeInput = it.filter(Char::isDigit)
|
||||
error = null
|
||||
result = null
|
||||
},
|
||||
label = { Text("Only events before (unix seconds)") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) {
|
||||
is AttestationOutcome.Failure -> {
|
||||
error = outcome.message
|
||||
result = null
|
||||
}
|
||||
is AttestationOutcome.Success -> {
|
||||
error = null
|
||||
result = outcome.attestation
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = agentInput.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Generate attestation")
|
||||
}
|
||||
|
||||
result?.let { attestation ->
|
||||
AttestationResultCard(
|
||||
attestation = attestation,
|
||||
onCopy = { scope.launch { clipboard.setText(attestation.toTagJson()) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttestationResultCard(
|
||||
attestation: OwnerAttestation,
|
||||
onCopy: () -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Signed attestation",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
"Grants: " +
|
||||
(attestation.conditions.ifEmpty { "any kind, any time (unrestricted)" }),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = attestation.toTagJson(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(onClick = onCopy) {
|
||||
Text("Copy tag")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
"⚠ Hand this to the agent operator only. While it is valid and you remain a " +
|
||||
"workspace member, the relay lets this agent post as a member under the " +
|
||||
"conditions above.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface AttestationOutcome {
|
||||
data class Success(
|
||||
val attestation: OwnerAttestation,
|
||||
) : AttestationOutcome
|
||||
|
||||
data class Failure(
|
||||
val message: String,
|
||||
) : AttestationOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form inputs and signs an [OwnerAttestation], or returns a
|
||||
* human-readable failure. Kept out of composition so the signing (raw-key) path is a
|
||||
* plain function.
|
||||
*/
|
||||
private fun buildAttestation(
|
||||
agentInput: String,
|
||||
kindInput: String,
|
||||
afterInput: String,
|
||||
beforeInput: String,
|
||||
ownerKey: KeyPair,
|
||||
): AttestationOutcome {
|
||||
val agentHex =
|
||||
decodePublicKeyAsHexOrNull(agentInput.trim())
|
||||
?: return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
|
||||
if (!agentHex.isValid()) {
|
||||
return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
|
||||
}
|
||||
|
||||
val kind =
|
||||
if (kindInput.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val parsed = kindInput.toIntOrNull()
|
||||
if (parsed == null || parsed !in 0..65535) {
|
||||
return AttestationOutcome.Failure("Kind must be between 0 and 65535.")
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
val after = parseOptionalUnix(afterInput) ?: return AttestationOutcome.Failure("\"After\" must be a unix time in 0–4294967295.")
|
||||
val before = parseOptionalUnix(beforeInput) ?: return AttestationOutcome.Failure("\"Before\" must be a unix time in 0–4294967295.")
|
||||
|
||||
val conditions = AttestationConditions(kind = kind, createdAtBefore = before.value, createdAtAfter = after.value)
|
||||
|
||||
return try {
|
||||
AttestationOutcome.Success(OwnerAttestation.sign(agentHex, conditions, ownerKey))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
AttestationOutcome.Failure(e.message ?: "Could not sign the attestation.")
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps a nullable parse so "absent" and "invalid" are distinguishable from the caller. */
|
||||
private class OptionalUnix(
|
||||
val value: Long?,
|
||||
)
|
||||
|
||||
private fun parseOptionalUnix(input: String): OptionalUnix? {
|
||||
if (input.isBlank()) return OptionalUnix(null)
|
||||
val parsed = input.toLongOrNull() ?: return null
|
||||
if (parsed !in 0..4294967295L) return null
|
||||
return OptionalUnix(parsed)
|
||||
}
|
||||
|
||||
/** Serializes the `auth` tag to a JSON array string (values are hex / canonical ASCII). */
|
||||
private fun OwnerAttestation.toTagJson(): String = toTag().joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
|
||||
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* 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.buzz
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentUsageSummary
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.TokenTotals
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* The workspace owner's agent-owner console — a dashboard over the fleet of AI agents
|
||||
* that publish to them. Three tabs:
|
||||
* - **Costs** — fleet totals and a per-agent breakdown (turns, sessions, tokens, spend),
|
||||
* derived from decrypted NIP-AM turn metrics (`kind:44200`).
|
||||
* - **Personas** — the owner's published persona definitions (NIP-AP `kind:30175`).
|
||||
* - **Observer** — a live stream of ephemeral NIP-AO telemetry frames (`kind:24200`).
|
||||
*
|
||||
* A "Attest" FAB opens [AgentAttestationScreen] to issue a NIP-OA authorization for an
|
||||
* agent key. Data comes from [AgentConsoleViewModel], keyed by the owner pubkey so the
|
||||
* fetch/decrypt work survives navigation. The tabs are read-only; persona editing is a
|
||||
* follow-up.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentConsoleScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
val viewModel: AgentConsoleViewModel = viewModel(key = "AgentConsole-$pubkey")
|
||||
|
||||
viewModel.bindAccountIfMissing(accountViewModel.account)
|
||||
|
||||
val metrics by viewModel.metrics.collectAsStateWithLifecycle()
|
||||
val personas by viewModel.personas.collectAsStateWithLifecycle()
|
||||
val observerFrames by viewModel.observerFrames.collectAsStateWithLifecycle()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val tabs = remember { listOf("Costs", "Personas", "Observer") }
|
||||
|
||||
// The observer stream is a live REQ; only keep it open while its tab is on screen.
|
||||
if (selectedTab == 2) {
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.startObserving()
|
||||
onDispose { viewModel.stopObserving() }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton("Agent Console", nav) },
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text("Attest") },
|
||||
icon = { Icon(symbol = MaterialSymbols.Key, contentDescription = null) },
|
||||
onClick = { nav.nav(Route.AgentAttestation) },
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (selectedTab) {
|
||||
0 -> CostsTab(metrics)
|
||||
1 -> PersonasTab(personas, nav)
|
||||
else -> ObserverTab(observerFrames)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CostsTab(metrics: AgentFleetMetrics) {
|
||||
if (metrics.agents.isEmpty()) {
|
||||
EmptyState("No agent turn metrics yet. Metrics appear once your agents publish kind:44200 events to you.")
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
FleetSummaryCard(metrics)
|
||||
}
|
||||
items(metrics.agents) { agent ->
|
||||
AgentCard(agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FleetSummaryCard(metrics: AgentFleetMetrics) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "Fleet total",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = formatCost(metrics.totals.costUsd),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
MetaRow("Agents", metrics.agents.size.toString())
|
||||
MetaRow("Sessions", metrics.totalSessions.toString())
|
||||
MetaRow("Turns", metrics.totalTurns.toString())
|
||||
TokenBreakdown(metrics.totals)
|
||||
if (metrics.hasUnreliableEstimates) {
|
||||
Text(
|
||||
text = "⚠ Some totals are estimated from per-turn deltas (a session reported no cumulative counts).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgentCard(agent: AgentUsageSummary) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = shortKey(agent.agentPubKey),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = formatCost(agent.totals.costUsd),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
MetaRow("Sessions", agent.sessions.toString())
|
||||
MetaRow("Turns", agent.turns.toString())
|
||||
if (agent.models.isNotEmpty()) MetaRow("Models", agent.models.sorted().joinToString(", "))
|
||||
if (agent.harnesses.isNotEmpty()) MetaRow("Harness", agent.harnesses.sorted().joinToString(", "))
|
||||
agent.lastActivity?.let { MetaRow("Last turn", it) }
|
||||
TokenBreakdown(agent.totals)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenBreakdown(totals: TokenTotals) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
MetaRow("Input tokens", formatCount(totals.inputTokens))
|
||||
MetaRow("Output tokens", formatCount(totals.outputTokens))
|
||||
MetaRow("Total tokens", formatCount(totals.totalTokens))
|
||||
if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens))
|
||||
if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonasTab(
|
||||
personas: List<AgentConsoleViewModel.PersonaCard>,
|
||||
nav: INav,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
OutlinedButton(
|
||||
onClick = { nav.nav(Route.AgentPersonaEdit()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.AddCircle, contentDescription = null)
|
||||
Text(" New persona")
|
||||
}
|
||||
}
|
||||
if (personas.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No personas published yet. Create one to define an agent (kind:30175).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(personas) { persona ->
|
||||
PersonaCardView(persona) { nav.nav(Route.AgentPersonaEdit(persona.slug)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaCardView(
|
||||
persona: AgentConsoleViewModel.PersonaCard,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = persona.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = persona.slug,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
persona.model?.let { MetaRow("Model", it) }
|
||||
persona.provider?.let { MetaRow("Provider", it) }
|
||||
persona.runtime?.let { MetaRow("Runtime", it) }
|
||||
persona.systemPrompt?.takeIf { it.isNotBlank() }?.let {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserverTab(frames: List<AgentConsoleViewModel.ObserverRow>) {
|
||||
if (frames.isEmpty()) {
|
||||
EmptyState("Listening for live agent telemetry (kind:24200). Frames appear here while your agents are running.")
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(frames) { frame ->
|
||||
ObserverFrameRow(frame)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserverFrameRow(frame: AgentConsoleViewModel.ObserverRow) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = frame.kind,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = frame.timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
val context =
|
||||
buildString {
|
||||
append(shortKey(frame.agentPubKey))
|
||||
frame.sessionId?.let { append(" · session ${it.take(8)}") }
|
||||
frame.turnId?.let { append(" · turn ${it.take(8)}") }
|
||||
append(" · #${frame.seq}")
|
||||
}
|
||||
Text(
|
||||
text = context,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaRow(
|
||||
label: String,
|
||||
value: String,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(message: String) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCost(cost: Double): String = "$" + ((cost * 100).toLong() / 100.0).toString().let { padCents(it) }
|
||||
|
||||
/** Pads a dollar string to exactly two decimals (e.g. "1.5" -> "1.50", "3" -> "3.00"). */
|
||||
private fun padCents(value: String): String {
|
||||
val dot = value.indexOf('.')
|
||||
if (dot < 0) return "$value.00"
|
||||
val decimals = value.length - dot - 1
|
||||
return when {
|
||||
decimals >= 2 -> value.substring(0, dot + 3)
|
||||
decimals == 1 -> value + "0"
|
||||
else -> value + "00"
|
||||
}
|
||||
}
|
||||
|
||||
/** Groups a token count with thin spaces every three digits (1234567 -> "1 234 567"). */
|
||||
private fun formatCount(n: Long): String {
|
||||
val s = n.toString()
|
||||
if (s.length <= 3) return s
|
||||
val sb = StringBuilder()
|
||||
val firstGroup = s.length % 3
|
||||
if (firstGroup > 0) {
|
||||
sb.append(s, 0, firstGroup)
|
||||
}
|
||||
var i = firstGroup
|
||||
while (i < s.length) {
|
||||
if (sb.isNotEmpty()) sb.append(' ')
|
||||
sb.append(s, i, i + 3)
|
||||
i += 3
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun shortKey(hex: String): String = if (hex.length <= 16) hex else hex.take(8) + "…" + hex.takeLast(8)
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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.buzz
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.filter
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricPayload
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* Backing ViewModel for the [AgentConsoleScreen] — the workspace owner's read-only
|
||||
* dashboard over their AI-agent fleet.
|
||||
*
|
||||
* It reads two Buzz kinds the owner authored or received:
|
||||
* - **Personas** ([PersonaEvent], `kind:30175`) — plaintext, authored by the owner.
|
||||
* - **Turn metrics** ([AgentTurnMetricEvent], `kind:44200`) — NIP-44 ciphertext an
|
||||
* agent published to the owner (`p` tag = owner). Either party can decrypt; here the
|
||||
* owner's [Account.signer] does. Decryption is cached by event id so a refresh that
|
||||
* re-reads the cache never re-runs NIP-44 on an event it already opened.
|
||||
*
|
||||
* [refresh] fetches both kinds from the Buzz-dialect relays plus the owner's outbox set,
|
||||
* lets [LocalCache] consume them, then re-derives the aggregate via the pure
|
||||
* [AgentFleetAggregator] (which owns the cost/token semantics). The ViewModel is keyed by
|
||||
* the owner pubkey in the screen, so one instance survives navigation in and out.
|
||||
*/
|
||||
class AgentConsoleViewModel : ViewModel() {
|
||||
@Volatile private var account: Account? = null
|
||||
|
||||
/** event id -> decrypted payload (null = decryption failed; don't retry). */
|
||||
private val decryptCache = HashMap<HexKey, AgentTurnMetricPayload?>()
|
||||
private val refreshMutex = Mutex()
|
||||
|
||||
private val _metrics = MutableStateFlow(AgentFleetMetrics.EMPTY)
|
||||
val metrics: StateFlow<AgentFleetMetrics> = _metrics.asStateFlow()
|
||||
|
||||
private val _personas = MutableStateFlow<List<PersonaCard>>(emptyList())
|
||||
val personas: StateFlow<List<PersonaCard>> = _personas.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _observerFrames = MutableStateFlow<List<ObserverRow>>(emptyList())
|
||||
val observerFrames: StateFlow<List<ObserverRow>> = _observerFrames.asStateFlow()
|
||||
|
||||
private var observerJob: Job? = null
|
||||
|
||||
/** Dedups ephemeral frames across relays and re-emissions (accessed off multiple readers). */
|
||||
private val observerSeen = Collections.synchronizedSet(HashSet<HexKey>())
|
||||
|
||||
fun bindAccountIfMissing(account: Account) {
|
||||
if (this.account != null) return
|
||||
this.account = account
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val account = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
refreshMutex.withLock {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
fetchFromRelays(account)
|
||||
reloadFromCache(account)
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot paged fetch of the owner's personas (authored by owner) and turn metrics
|
||||
* (addressed to the owner via the `p` tag) from every Buzz-dialect relay and the
|
||||
* owner's own outbox relays. Events land in [LocalCache] via the normal consume path;
|
||||
* this only primes the cache before [reloadFromCache] reads it back.
|
||||
*/
|
||||
private suspend fun fetchFromRelays(account: Account) {
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val relays = BuzzRelayDialect.flow.value + account.outboxRelays.flow.value
|
||||
if (relays.isEmpty()) return
|
||||
|
||||
val filters =
|
||||
listOf(
|
||||
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
|
||||
Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(myPubkey)),
|
||||
)
|
||||
|
||||
account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { _, _ -> }
|
||||
}
|
||||
|
||||
private suspend fun reloadFromCache(account: Account) {
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val signer = account.signer
|
||||
|
||||
_personas.value =
|
||||
LocalCache.addressables
|
||||
.filter(PersonaEvent.KIND, myPubkey) { _, note -> note.event is PersonaEvent }
|
||||
.mapNotNull { note ->
|
||||
val event = note.event as? PersonaEvent ?: return@mapNotNull null
|
||||
val content = event.personaOrNull()
|
||||
PersonaCard(
|
||||
slug = event.slug() ?: "",
|
||||
displayName = content?.displayName ?: event.slug() ?: "",
|
||||
model = content?.model,
|
||||
runtime = content?.runtime,
|
||||
provider = content?.provider,
|
||||
systemPrompt = content?.systemPrompt,
|
||||
)
|
||||
}.sortedBy { it.displayName.lowercase() }
|
||||
|
||||
val metricNotes =
|
||||
LocalCache.filter(
|
||||
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
|
||||
)
|
||||
|
||||
val decrypted =
|
||||
metricNotes.mapNotNull { note ->
|
||||
val event = note.event as? AgentTurnMetricEvent ?: return@mapNotNull null
|
||||
val payload =
|
||||
if (decryptCache.containsKey(event.id)) {
|
||||
decryptCache[event.id]
|
||||
} else {
|
||||
event.decryptOrNull(signer).also { decryptCache[event.id] = it }
|
||||
} ?: return@mapNotNull null
|
||||
val agent = event.agentPubKey() ?: event.pubKey
|
||||
agent to payload
|
||||
}
|
||||
|
||||
_metrics.value = AgentFleetAggregator.aggregate(decrypted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a live subscription to the owner's ephemeral observer telemetry frames
|
||||
* ([ObserverFrameEvent], `kind:24200`, `p` = owner) across every Buzz-dialect relay,
|
||||
* decrypts the `frame:telemetry` bodies, and pushes them newest-first into
|
||||
* [observerFrames] (bounded to [MAX_OBSERVER_ROWS]). Idempotent; call [stopObserving]
|
||||
* to tear the subscription down. Observer frames are never stored by relays or
|
||||
* [LocalCache], so this live REQ is the only way to see them.
|
||||
*/
|
||||
fun startObserving() {
|
||||
val account = account ?: return
|
||||
if (observerJob != null) return
|
||||
|
||||
val relays = BuzzRelayDialect.flow.value
|
||||
if (relays.isEmpty()) return
|
||||
|
||||
val signer = account.signer
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val filter = Filter(kinds = listOf(ObserverFrameEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
|
||||
|
||||
observerJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
account.client.subscribeAsFlow(relay, filter).collect { events ->
|
||||
val fresh =
|
||||
events
|
||||
.filterIsInstance<ObserverFrameEvent>()
|
||||
.filter { observerSeen.add(it.id) }
|
||||
if (fresh.isEmpty()) return@collect
|
||||
|
||||
val rows =
|
||||
fresh.mapNotNull { frame ->
|
||||
if (frame.frame() != FrameTag.TELEMETRY) return@mapNotNull null
|
||||
val payload = frame.decryptTelemetryOrNull(signer) ?: return@mapNotNull null
|
||||
ObserverRow(
|
||||
seq = payload.seq,
|
||||
timestamp = payload.timestamp,
|
||||
kind = payload.kind,
|
||||
agentPubKey = frame.agentPubKey() ?: frame.pubKey,
|
||||
sessionId = payload.sessionId,
|
||||
turnId = payload.turnId,
|
||||
)
|
||||
}
|
||||
if (rows.isNotEmpty()) {
|
||||
_observerFrames.update { existing ->
|
||||
(rows + existing)
|
||||
.sortedByDescending { it.timestamp }
|
||||
.take(MAX_OBSERVER_ROWS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopObserving() {
|
||||
observerJob?.cancel()
|
||||
observerJob = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stopObserving()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/** One decrypted observer telemetry frame rendered on the Observer tab. */
|
||||
@Immutable
|
||||
data class ObserverRow(
|
||||
val seq: Long,
|
||||
val timestamp: String,
|
||||
val kind: String,
|
||||
val agentPubKey: HexKey,
|
||||
val sessionId: String?,
|
||||
val turnId: String?,
|
||||
)
|
||||
|
||||
/** A persona rendered on the Personas tab; a flattened projection of [PersonaEvent]. */
|
||||
@Immutable
|
||||
data class PersonaCard(
|
||||
val slug: String,
|
||||
val displayName: String,
|
||||
val model: String?,
|
||||
val runtime: String?,
|
||||
val provider: String?,
|
||||
val systemPrompt: String?,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Ring size for the ephemeral observer stream — enough to scroll recent activity. */
|
||||
const val MAX_OBSERVER_ROWS = 200
|
||||
}
|
||||
}
|
||||
@@ -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.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Create or edit a Buzz Agent Persona (NIP-AP `kind:30175`). [slug] null → a new persona;
|
||||
* otherwise edits the existing one (slug locked). Publishes to the workspace's Buzz relays
|
||||
* on save and pops back. Backed by [AgentPersonaEditViewModel], keyed by owner+slug so an
|
||||
* in-progress edit survives recomposition.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentPersonaEditScreen(
|
||||
slug: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
val viewModel: AgentPersonaEditViewModel = viewModel(key = "PersonaEdit-$pubkey-${slug ?: "new"}")
|
||||
viewModel.bind(accountViewModel.account, slug)
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val title = if (slug == null) "New persona" else "Edit persona"
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(title, nav) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.slug,
|
||||
onValueChange = viewModel::onSlugChange,
|
||||
label = { Text("Slug (persona id)") },
|
||||
singleLine = true,
|
||||
enabled = !state.slugLocked,
|
||||
supportingText = { Text("a-z, 0-9, '-' or '_'. Cannot change after creation.") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.displayName,
|
||||
onValueChange = viewModel::onDisplayNameChange,
|
||||
label = { Text("Display name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.systemPrompt,
|
||||
onValueChange = viewModel::onSystemPromptChange,
|
||||
label = { Text("System prompt") },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.model,
|
||||
onValueChange = viewModel::onModelChange,
|
||||
label = { Text("Model (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.provider,
|
||||
onValueChange = viewModel::onProviderChange,
|
||||
label = { Text("Provider (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.runtime,
|
||||
onValueChange = viewModel::onRuntimeChange,
|
||||
label = { Text("Runtime (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.avatarUrl,
|
||||
onValueChange = viewModel::onAvatarUrlChange,
|
||||
label = { Text("Avatar URL (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
state.error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.save(onDone = nav::popBack) },
|
||||
enabled = state.canSave,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (state.isSaving) "Publishing…" else "Publish persona")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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.buzz
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaContent
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Backing ViewModel for [AgentPersonaEditScreen] — creates or edits a Buzz Agent Persona
|
||||
* (NIP-AP `kind:30175`), a world-readable addressable event authored by the workspace
|
||||
* owner and addressed by `(owner, 30175, slug)`.
|
||||
*
|
||||
* 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
|
||||
* than dropped on the next publish. The 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
|
||||
* none are known), so the workspace and other members see it.
|
||||
*/
|
||||
class AgentPersonaEditViewModel : ViewModel() {
|
||||
@Volatile private var account: Account? = null
|
||||
|
||||
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
|
||||
private var existing: PersonaContent? = null
|
||||
private var editingSlug: String? = null
|
||||
|
||||
private val _state = MutableStateFlow(FormState())
|
||||
val state: StateFlow<FormState> = _state.asStateFlow()
|
||||
|
||||
/** Binds the account and, when [slug] names an existing persona, seeds the form from it. */
|
||||
fun bind(
|
||||
account: Account,
|
||||
slug: String?,
|
||||
) {
|
||||
if (this.account != null) return
|
||||
this.account = account
|
||||
|
||||
if (slug.isNullOrBlank()) return
|
||||
val note = LocalCache.getAddressableNoteIfExists(Address(PersonaEvent.KIND, account.userProfile().pubkeyHex, slug))
|
||||
val event = note?.event as? PersonaEvent ?: return
|
||||
val content = event.personaOrNull() ?: return
|
||||
existing = content
|
||||
editingSlug = slug
|
||||
_state.value =
|
||||
FormState(
|
||||
slug = slug,
|
||||
slugLocked = true,
|
||||
displayName = content.displayName,
|
||||
systemPrompt = content.systemPrompt.orEmpty(),
|
||||
model = content.model.orEmpty(),
|
||||
runtime = content.runtime.orEmpty(),
|
||||
provider = content.provider.orEmpty(),
|
||||
avatarUrl = content.avatarUrl.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
fun onSlugChange(v: String) = _state.update { it.copy(slug = v.trim(), error = null) }
|
||||
|
||||
fun onDisplayNameChange(v: String) = _state.update { it.copy(displayName = v, error = null) }
|
||||
|
||||
fun onSystemPromptChange(v: String) = _state.update { it.copy(systemPrompt = v, error = null) }
|
||||
|
||||
fun onModelChange(v: String) = _state.update { it.copy(model = v.trim(), error = null) }
|
||||
|
||||
fun onRuntimeChange(v: String) = _state.update { it.copy(runtime = v.trim(), error = null) }
|
||||
|
||||
fun onProviderChange(v: String) = _state.update { it.copy(provider = v.trim(), error = null) }
|
||||
|
||||
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun save(onDone: () -> Unit) {
|
||||
val account = account ?: return
|
||||
val current = _state.value
|
||||
|
||||
val slug = current.slug.trim()
|
||||
if (!isValidSlug(slug)) {
|
||||
_state.update { it.copy(error = "Slug must match a-z, 0-9, '-' or '_' (max 64 chars).") }
|
||||
return
|
||||
}
|
||||
if (current.displayName.isBlank()) {
|
||||
_state.update { it.copy(error = "Display name is required.") }
|
||||
return
|
||||
}
|
||||
|
||||
_state.update { it.copy(isSaving = true, error = null) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val base = existing ?: PersonaContent(displayName = current.displayName)
|
||||
val content =
|
||||
base.copy(
|
||||
displayName = current.displayName.trim(),
|
||||
systemPrompt = current.systemPrompt.blankToNull(),
|
||||
model = current.model.blankToNull(),
|
||||
runtime = current.runtime.blankToNull(),
|
||||
provider = current.provider.blankToNull(),
|
||||
avatarUrl = current.avatarUrl.blankToNull(),
|
||||
)
|
||||
|
||||
val template = PersonaEvent.build(content, slug)
|
||||
account.signAndSendPrivatelyOrBroadcast(template) {
|
||||
BuzzRelayDialect.flow.value
|
||||
.toList()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) { onDone() }
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
_state.update { it.copy(isSaving = false, error = "Failed to publish: ${e.message ?: e::class.simpleName}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.blankToNull(): String? = trim().takeIf { it.isNotBlank() }
|
||||
|
||||
data class FormState(
|
||||
val slug: String = "",
|
||||
/** True when editing an existing persona — the slug (address `d` tag) can't change. */
|
||||
val slugLocked: Boolean = false,
|
||||
val displayName: String = "",
|
||||
val systemPrompt: String = "",
|
||||
val model: String = "",
|
||||
val runtime: String = "",
|
||||
val provider: String = "",
|
||||
val avatarUrl: String = "",
|
||||
val isSaving: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val canSave: Boolean get() = slug.isNotBlank() && displayName.isNotBlank() && !isSaving
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
/** The persona slug grammar enforced by the relay (`validate_persona_envelope`). */
|
||||
fun isValidSlug(slug: String): Boolean = SLUG_REGEX.matches(slug)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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.buzz
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Renders a Buzz workspace **canvas** (NIP kind 40100): the newest shared markdown
|
||||
* document for a channel. The canvas is overlay state held in [BuzzWorkspaceStates]
|
||||
* (never a timeline row — a workspace has one live canvas, last-write-wins), so this
|
||||
* reads the registry by the channel's `h` id and re-composes off `canvasUpdates` when a
|
||||
* newer revision lands.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzCanvasScreen(
|
||||
channelId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
|
||||
// Re-read canvasNote whenever a newer canvas is consumed.
|
||||
val version by state.canvasUpdates.collectAsStateWithLifecycle()
|
||||
val canvas = remember(version) { state.canvasNote }
|
||||
val content = canvas?.event?.content
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_canvas_title), nav) },
|
||||
) { padding ->
|
||||
if (content.isNullOrBlank() || canvas == null) {
|
||||
Box(
|
||||
modifier = Modifier.padding(padding).fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_canvas_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
val bgColor = remember { mutableStateOf(Color.Transparent) }
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = content,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier,
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = bgColor,
|
||||
id = canvas.idHex,
|
||||
callbackUri = canvas.toNostrUri(),
|
||||
authorPubKey = canvas.author?.pubkeyHex,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* 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.buzz
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
|
||||
/**
|
||||
* The **Workspaces** tab — a first-class navigation home for `block/buzz` workspaces,
|
||||
* giving Buzz a distinct identity instead of hiding inside the generic relay-group list.
|
||||
* A Buzz workspace is a NIP-29 group on a Buzz-dialect relay, so this filters the user's
|
||||
* joined groups to those relays ([BuzzRelayDialect]) and adds the Buzz-only surfaces (the
|
||||
* Agent Console) up top.
|
||||
*
|
||||
* Read-only aggregation: it reuses the always-on relay-group state subscription (mounted
|
||||
* at login), so no per-screen fetch is needed — it just projects the joined set.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzWorkspacesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
|
||||
.collectAsStateWithLifecycle()
|
||||
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
|
||||
|
||||
val workspaces =
|
||||
remember(joined, buzzRelays) {
|
||||
joined
|
||||
.mapNotNull { tag ->
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null
|
||||
if (relay !in buzzRelays) return@mapNotNull null
|
||||
GroupId(tag.groupId, relay)
|
||||
}.sortedBy { it.id }
|
||||
}
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.BuzzWorkspaces, nav, accountViewModel) { route ->
|
||||
nav.navBottomBar(route)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item { AgentConsoleHeroCard(onClick = { nav.nav(Route.AgentConsole) }) }
|
||||
|
||||
if (workspaces.isEmpty()) {
|
||||
item { EmptyWorkspaces(onBrowse = { nav.nav(Route.RelayGroups) }) }
|
||||
} else {
|
||||
item {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
items(workspaces, key = { it.id + it.relayUrl.url }) { groupId ->
|
||||
WorkspaceRow(groupId, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bold accent card leading to the Buzz-only Agent Console — the tab's signature surface. */
|
||||
@Composable
|
||||
private fun AgentConsoleHeroCard(onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_console_card_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_console_card_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One workspace: a colored monogram avatar, live name + host, member count, unread dot. */
|
||||
@Composable
|
||||
private fun WorkspaceRow(
|
||||
groupId: GroupId,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
|
||||
val channelState by observeChannel(baseChannel, accountViewModel)
|
||||
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
|
||||
|
||||
val hasUnread by remember(groupId) {
|
||||
relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId)
|
||||
}.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
val name = channel.toBestDisplayName()
|
||||
val memberCount = channel.memberCount()
|
||||
|
||||
Card(
|
||||
onClick = { nav.nav(routeFor(channel)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WorkspaceAvatar(name = name, seed = groupId.id)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = groupId.relayUrl.displayUrl(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (memberCount > 0) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Group,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Text(
|
||||
text = "$memberCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (hasUnread) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A round monogram whose color is derived deterministically from the workspace id. */
|
||||
@Composable
|
||||
private fun WorkspaceAvatar(
|
||||
name: String,
|
||||
seed: String,
|
||||
) {
|
||||
val hue = remember(seed) { (seed.hashCode().toLong() and 0xFFFFFF).toFloat() % 360f }
|
||||
val color = remember(hue) { Color.hsl(hue, 0.55f, 0.5f) }
|
||||
val initial =
|
||||
remember(name) {
|
||||
name
|
||||
.trim()
|
||||
.firstOrNull()
|
||||
?.uppercaseChar()
|
||||
?.toString() ?: "#"
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(42.dp).clip(CircleShape).background(color),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initial,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Inviting empty state: what a Buzz workspace is + a way to go find one. */
|
||||
@Composable
|
||||
private fun EmptyWorkspaces(onBrowse: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_workspaces_empty_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.size(2.dp))
|
||||
FilledTonalButton(onClick = onBrowse) {
|
||||
Text(stringRes(R.string.buzz_workspaces_browse))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ fun RefreshingChatroomFeedView(
|
||||
// callers with no external jump affordance.
|
||||
jumpToNoteId: State<String?>? = null,
|
||||
onJumpHandled: () -> Unit = {},
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
|
||||
listStateObserver(listState)
|
||||
@@ -97,6 +98,7 @@ fun RefreshingChatroomFeedView(
|
||||
sentinels,
|
||||
jumpToNoteId,
|
||||
onJumpHandled,
|
||||
onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +118,7 @@ fun RenderChatFeedView(
|
||||
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
|
||||
jumpToNoteId: State<String?>? = null,
|
||||
onJumpHandled: () -> Unit = {},
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
val feedState by feed.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -148,6 +151,7 @@ fun RenderChatFeedView(
|
||||
sentinels,
|
||||
jumpToNoteId,
|
||||
onJumpHandled,
|
||||
onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -169,6 +173,7 @@ fun ChatFeedLoaded(
|
||||
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
|
||||
jumpToNoteId: State<String?>? = null,
|
||||
onJumpHandled: () -> Unit = {},
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -263,6 +268,7 @@ fun ChatFeedLoaded(
|
||||
onHighlightFinished = { highlightedNoteId.value = null },
|
||||
groupPosition = watchChatGroupPosition(newer, item, older),
|
||||
previousNoteId = older?.idHex,
|
||||
onWantsToEditBuzz = onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.reactionBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -119,6 +120,7 @@ fun ChatMessageActionSheet(
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
var showShareSheet by remember { mutableStateOf(false) }
|
||||
var wantsToEditPost by remember { mutableStateOf(false) }
|
||||
@@ -265,6 +267,23 @@ fun ChatMessageActionSheet(
|
||||
// Stage one: the primary chat action (reply / edit draft) is always shown.
|
||||
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
|
||||
|
||||
// Buzz: edit my own kind-40002 stream message (publishes a kind-40003 edit).
|
||||
// A 40002 event is inherently a Buzz message, so the type alone is the gate;
|
||||
// authorship restricts it to my own messages.
|
||||
val canEditBuzz =
|
||||
onWantsToEditBuzz != null &&
|
||||
note.event is StreamMessageV2Event &&
|
||||
note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
|
||||
if (canEditBuzz) {
|
||||
SectionDivider()
|
||||
TileRow {
|
||||
ActionTile(MaterialSymbols.Edit, stringRes(R.string.buzz_edit_message)) {
|
||||
onWantsToEditBuzz!!(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val handlers =
|
||||
NoteActionHandlers(
|
||||
onShare = { showShareSheet = true },
|
||||
|
||||
@@ -56,6 +56,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzForumVote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
|
||||
@@ -65,8 +70,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncr
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
|
||||
@@ -104,6 +114,9 @@ fun ChatroomMessageCompose(
|
||||
// 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.
|
||||
onBlank: (@Composable () -> Unit)? = null,
|
||||
// Buzz-only: edit my own kind-40002 stream message (publishes a 40003 edit). Null for
|
||||
// every non-Buzz chat surface, which hides the action.
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
// Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted
|
||||
// chat message renders with the chat reply design instead of the quoted-note card.
|
||||
@@ -129,6 +142,19 @@ fun ChatroomMessageCompose(
|
||||
RenderChatClip(baseNote, accountViewModel, nav)
|
||||
} else if (event is ChannelCreateEvent || event is ChannelMetadataEvent) {
|
||||
RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav)
|
||||
} else if (event is SystemMessageEvent) {
|
||||
// Buzz kind-40099: relay-signed room narration (join/leave/topic).
|
||||
RenderBuzzSystemMessage(baseNote)
|
||||
} else if (event is StreamMessageDiffEvent) {
|
||||
// Buzz kind-40008: a code/text diff pushed into the channel.
|
||||
RenderBuzzDiff(baseNote)
|
||||
} else if (event is ForumVoteEvent) {
|
||||
// Buzz kind-45002: a forum up/down vote.
|
||||
RenderBuzzForumVote(baseNote)
|
||||
} else if (isBuzzActivityRow(event)) {
|
||||
// Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles
|
||||
// especially must be caught here — their content is JSON, not chat text.
|
||||
RenderBuzzActivityRow(baseNote)
|
||||
} else {
|
||||
NormalChatNote(
|
||||
baseNote,
|
||||
@@ -145,6 +171,7 @@ fun ChatroomMessageCompose(
|
||||
onHighlightFinished,
|
||||
groupPosition,
|
||||
previousNoteId,
|
||||
onWantsToEditBuzz,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -175,6 +202,7 @@ fun NormalChatNote(
|
||||
onHighlightFinished: (() -> Unit)? = null,
|
||||
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
|
||||
previousNoteId: HexKey? = null,
|
||||
onWantsToEditBuzz: ((Note) -> Unit)? = null,
|
||||
) {
|
||||
// A geohash chat renders "as" its anonymous per-cell identity (and the account, when posting as
|
||||
// self); LocalChatActingIdentities lets the renderer treat those pubkeys as "me" (alignment,
|
||||
@@ -305,6 +333,7 @@ fun NormalChatNote(
|
||||
onDismiss = onDismiss,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onWantsToEditBuzz = onWantsToEditBuzz,
|
||||
)
|
||||
},
|
||||
reactionsRow =
|
||||
@@ -582,7 +611,17 @@ fun NoteRow(
|
||||
note.event is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
|
||||
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
|
||||
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
|
||||
else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||
else -> {
|
||||
// Buzz channels overlay kind-40003 edits on their messages: when one
|
||||
// exists, render the newest edit's content instead of the stale
|
||||
// original. Null for every non-Buzz chat surface.
|
||||
val buzzEdit = observeBuzzEdit(note)
|
||||
if (buzzEdit != null) {
|
||||
RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||
} else {
|
||||
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* 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.feed.types
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatSystemMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
|
||||
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.stream.StreamMessageDiffEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
|
||||
/**
|
||||
* Observes the newest kind-40003 edit overlaying [note], recomposing when new edits
|
||||
* arrive. Returns null when the message is unedited or has no channel scope.
|
||||
*
|
||||
* Resolution goes through [BuzzWorkspaceStates] keyed by the note's `h` channel id
|
||||
* (a Buzz UUID) rather than any channel object: the state exists independently of
|
||||
* when — or whether — the channel materialized, so a row composed before the first
|
||||
* edit arrived still starts rendering overlays the moment one lands.
|
||||
*/
|
||||
@Composable
|
||||
fun observeBuzzEdit(note: Note): Note? {
|
||||
val channelId = remember(note) { note.event?.groupId() } ?: return null
|
||||
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
|
||||
// Subscribing to the version counter is what re-runs editFor on new arrivals.
|
||||
val version by state.editUpdates.collectAsState()
|
||||
return remember(note, version) { state.editFor(note.idHex) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz stream message whose content has been superseded by a kind-40003 edit:
|
||||
* renders the NEWEST edit's content (never the stale original) plus an "(edited)"
|
||||
* marker, mirroring Buzz's own last-write-wins presentation.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzEditedNote(
|
||||
note: Note,
|
||||
editNote: Note,
|
||||
canPreview: Boolean,
|
||||
innerQuote: Boolean,
|
||||
bgColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// The edit note may still be loading; fall back to the original rendering rather
|
||||
// than committing to an edited branch that would show a blank row.
|
||||
val content = editNote.event?.content
|
||||
if (content == null) {
|
||||
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
|
||||
return
|
||||
}
|
||||
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
Column {
|
||||
TranslatableRichTextViewer(
|
||||
content = content,
|
||||
canPreview = canPreview,
|
||||
quotesLeft = if (innerQuote) 0 else 1,
|
||||
modifier = Modifier,
|
||||
tags = tags,
|
||||
backgroundColor = bgColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
authorPubKey = note.author?.pubkeyHex,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_message_edited),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz kind-40099 system message ("X joined", "channel created", "topic changed"):
|
||||
* narrates the room rather than speaking in it, so it renders as a centered system
|
||||
* line like the NIP-28 admin events, from the relay-signed JSON payload.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzSystemMessage(note: Note) {
|
||||
val event = note.event as? SystemMessageEvent ?: return
|
||||
// Relay-emitted machine text (join/leave/topic); shown as-is rather than through
|
||||
// string resources — the payload vocabulary is Buzz's, not ours to translate yet.
|
||||
val text =
|
||||
remember(event) {
|
||||
val payload = event.payload()
|
||||
when (payload?.type) {
|
||||
"topic_changed" -> payload.topic?.let { "topic: $it" } ?: "topic changed"
|
||||
"purpose_changed" -> payload.purpose?.let { "purpose: $it" } ?: "purpose changed"
|
||||
else -> payload?.type?.replace('_', ' ')
|
||||
} ?: event.content.take(120)
|
||||
}
|
||||
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the Buzz agent-job and huddle lifecycle kinds that [RenderBuzzActivityRow]
|
||||
* narrates as a centered system line rather than a chat bubble. Huddle events in
|
||||
* particular MUST be caught here — their `content` is JSON, so rendering them as a plain
|
||||
* chat message would show raw `{"ephemeral_channel_id":…}`.
|
||||
*/
|
||||
fun isBuzzActivityRow(event: Event?): Boolean =
|
||||
event is JobRequestEvent ||
|
||||
event is JobAcceptedEvent ||
|
||||
event is JobProgressEvent ||
|
||||
event is JobResultEvent ||
|
||||
event is JobCancelEvent ||
|
||||
event is JobErrorEvent ||
|
||||
event is HuddleStartedEvent ||
|
||||
event is HuddleParticipantJoinedEvent ||
|
||||
event is HuddleParticipantLeftEvent ||
|
||||
event is HuddleEndedEvent
|
||||
|
||||
/**
|
||||
* Narrates a Buzz agent-job or huddle lifecycle event as a centered system line. The
|
||||
* label is derived from the kind; job progress/result/error also append a short content
|
||||
* snippet (the human-readable status/result/error the agent wrote).
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzActivityRow(note: Note) {
|
||||
val event = note.event ?: return
|
||||
val text =
|
||||
remember(event) {
|
||||
when (event) {
|
||||
is JobRequestEvent -> "⚙ job requested" + event.request().snippet()
|
||||
is JobAcceptedEvent -> "⚙ job accepted"
|
||||
is JobProgressEvent -> "⚙ job progress" + (event.status()?.let { ": $it" } ?: "") + event.content.snippet()
|
||||
is JobResultEvent -> "⚙ job result" + event.result().snippet()
|
||||
is JobCancelEvent -> "⚙ job cancelled"
|
||||
is JobErrorEvent -> "⚠ job error" + event.error().snippet()
|
||||
is HuddleStartedEvent -> "🔊 huddle started"
|
||||
is HuddleParticipantJoinedEvent -> "🔊 someone joined the huddle"
|
||||
is HuddleParticipantLeftEvent -> "🔊 someone left the huddle"
|
||||
is HuddleEndedEvent -> "🔊 huddle ended"
|
||||
else -> event.content.take(120)
|
||||
}
|
||||
}
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
|
||||
/** A short one-line snippet of free-text content appended after a label, or "" if blank. */
|
||||
private fun String.snippet(): String {
|
||||
val oneLine = trim().replace('\n', ' ')
|
||||
if (oneLine.isEmpty()) return ""
|
||||
return ": " + if (oneLine.length > 80) oneLine.take(80) + "…" else oneLine
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz kind-40008 stream diff: a code/text diff pushed into the channel. Renders the
|
||||
* repo/commit/file header from the `DiffMeta` tags plus the diff body in a monospace,
|
||||
* horizontally-scrollable block, rather than the plain-text fallback (which mangles the
|
||||
* `+`/`-` gutters into wrapped prose).
|
||||
*/
|
||||
@Composable
|
||||
fun RenderBuzzDiff(note: Note) {
|
||||
val event = note.event as? StreamMessageDiffEvent ?: return
|
||||
val meta = remember(event) { event.diffMeta() }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
val header =
|
||||
remember(meta) {
|
||||
meta?.let {
|
||||
buildString {
|
||||
it.filePath?.let(::append)
|
||||
it.language?.let { lang -> append(if (isEmpty()) lang else " · $lang") }
|
||||
it.prNumber?.let { pr -> append(" · PR #$pr") }
|
||||
it.commitSha
|
||||
?.takeIf { sha -> sha.isNotBlank() }
|
||||
?.let { sha -> append(" · ${sha.take(8)}") }
|
||||
}.ifBlank { null }
|
||||
}
|
||||
}
|
||||
if (header != null) {
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = event.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
if (meta?.truncated == true) {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_diff_truncated),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */
|
||||
@Composable
|
||||
fun RenderBuzzForumVote(note: Note) {
|
||||
val event = note.event as? ForumVoteEvent ?: return
|
||||
// Content is the vote token ("+"/"-" or similar); show a compact glyph line.
|
||||
val text = remember(event) { if (event.content.trim().startsWith("-")) "▼ downvoted a post" else "▲ upvoted a post" }
|
||||
ChatSystemMessage(text = text)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* A live "… is typing" row for a Buzz workspace channel, driven by the ephemeral
|
||||
* kind-20002 heartbeats collected into [BuzzTypingState]. Placed just above the composer,
|
||||
* it slides in when someone starts typing and out when they stop (heartbeats age out of
|
||||
* the freshness window). Own typing is filtered by [myPubkey].
|
||||
*
|
||||
* The three animated dots are a small modern flourish; the wording collapses to a count
|
||||
* once more than two people type at once so the row never overflows.
|
||||
*/
|
||||
@Composable
|
||||
fun BuzzTypingIndicator(
|
||||
channelId: String,
|
||||
myPubkey: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val typingByChannel by BuzzTypingState.flow.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-evaluate on a light timer so typists fade out when their heartbeats go stale,
|
||||
// but only while this channel actually has any — an idle channel never wakes the loop.
|
||||
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
|
||||
val raw = typingByChannel[channelId]
|
||||
LaunchedEffect(channelId, raw) {
|
||||
if (raw.isNullOrEmpty()) return@LaunchedEffect
|
||||
while (true) {
|
||||
nowSecs = TimeUtils.now()
|
||||
if (raw.values.none { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }) break
|
||||
delay(2000L)
|
||||
}
|
||||
}
|
||||
|
||||
val typers =
|
||||
remember(raw, myPubkey, nowSecs) {
|
||||
(raw ?: emptyMap())
|
||||
.filterKeys { it != myPubkey }
|
||||
.filterValues { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }
|
||||
.keys
|
||||
.sorted()
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = typers.isNotEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TypingDots()
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = typingLabel(typers, accountViewModel),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun typingLabel(
|
||||
typers: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String =
|
||||
when (typers.size) {
|
||||
1 -> stringRes(R.string.buzz_typing_one, rememberTypistName(typers[0], accountViewModel))
|
||||
2 ->
|
||||
stringRes(
|
||||
R.string.buzz_typing_two,
|
||||
rememberTypistName(typers[0], accountViewModel),
|
||||
rememberTypistName(typers[1], accountViewModel),
|
||||
)
|
||||
else -> stringRes(R.string.buzz_typing_many)
|
||||
}
|
||||
|
||||
/** Three dots that bounce in a staggered wave — a lightweight, always-on micro-animation. */
|
||||
@Composable
|
||||
private fun TypingDots() {
|
||||
val transition = rememberInfiniteTransition(label = "buzz-typing")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
repeat(3) { index ->
|
||||
val phase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = 900, delayMillis = index * 150),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "dot-$index",
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(5.dp)
|
||||
.graphicsLayer {
|
||||
translationY = -3f * phase
|
||||
val s = 0.7f + 0.3f * phase
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
}.alpha(0.4f + 0.6f * phase)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */
|
||||
@Composable
|
||||
private fun rememberTypistName(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String {
|
||||
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) }
|
||||
val info by observeUserInfo(user, accountViewModel)
|
||||
return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() }
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
@@ -216,6 +217,7 @@ private fun ChannelView(
|
||||
avoidDraft = newPostModel.draftTag,
|
||||
onWantsToReply = newPostModel::reply,
|
||||
onWantsToEditDraft = newPostModel::editFromDraft,
|
||||
onWantsToEditBuzz = newPostModel::editBuzzMessage,
|
||||
jumpToNoteId = jumpToNoteId,
|
||||
onJumpHandled = { jumpToNoteId.value = null },
|
||||
// A status card at the oldest end: what it's reaching for while paging, "All caught up" when dry.
|
||||
@@ -251,6 +253,17 @@ private fun ChannelView(
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz workspaces surface a live "… is typing" row just above the composer, fed by
|
||||
// ephemeral kind-20002 heartbeats. Only rendered on a Buzz-dialect relay (the kind is
|
||||
// Buzz-only); a no-op otherwise.
|
||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||
BuzzTypingIndicator(
|
||||
channelId = channel.groupId.id,
|
||||
myPubkey = accountViewModel.userProfile().pubkeyHex,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
// NIP-29 relays reject writes from non-members, so only show the composer when the
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -50,6 +51,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
|
||||
@@ -164,6 +167,24 @@ fun RelayGroupTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz workspace canvas (kind 40100): shown only on a Buzz-dialect relay once
|
||||
// a canvas has arrived for this channel. Observing canvasUpdates flips it on
|
||||
// the moment the first canvas is consumed, without swapping the channel object.
|
||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||
val canvasState = remember(channel.groupId.id) { BuzzWorkspaceStates.getOrCreate(channel.groupId.id) }
|
||||
val canvasVersion by canvasState.canvasUpdates.collectAsState()
|
||||
val hasCanvas = remember(canvasVersion) { canvasState.canvasNote != null }
|
||||
if (hasCanvas) {
|
||||
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id)) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Dashboard,
|
||||
contentDescription = stringRes(R.string.buzz_canvas_title),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val naddr = channel.toNAddr()
|
||||
if (naddr != null) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
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.HuddleParticipantJoinedEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
|
||||
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
|
||||
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.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
|
||||
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 +104,58 @@ 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, requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
|
||||
* kinds simply match nothing. A dialect-gated version was tried and reverted: gating
|
||||
* creates a bootstrap hole (nothing asks for a Buzz kind until one is consumed) and a
|
||||
* worse one — history pages fetched before the mark advance their cursors past ranges
|
||||
* queried WITHOUT Buzz kinds, permanently skipping older workspace messages.
|
||||
*
|
||||
* All are `h`-scoped (`GroupIdTag`), so the same `#h` group REQ returns them:
|
||||
* - stream messages v2 (40002), edits (40003), diffs (40008), system rows (40099), canvas (40100)
|
||||
* - forum posts/votes/comments (45001-45003)
|
||||
* - agent jobs (43001-43006)
|
||||
* - huddle lifecycle (48100-48103)
|
||||
*
|
||||
* Consumption for every one of these already exists in `LocalCache` (see
|
||||
* `consumeBuzzTimelineEvent`); requesting them here is what lets them actually arrive for
|
||||
* a group feed instead of only appearing if another subscription happened to fetch them.
|
||||
*/
|
||||
val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
|
||||
listOf(
|
||||
StreamMessageV2Event.KIND,
|
||||
StreamMessageEditEvent.KIND,
|
||||
StreamMessageDiffEvent.KIND,
|
||||
SystemMessageEvent.KIND,
|
||||
CanvasEvent.KIND,
|
||||
ForumPostEvent.KIND,
|
||||
ForumVoteEvent.KIND,
|
||||
ForumCommentEvent.KIND,
|
||||
JobRequestEvent.KIND,
|
||||
JobAcceptedEvent.KIND,
|
||||
JobProgressEvent.KIND,
|
||||
JobResultEvent.KIND,
|
||||
JobCancelEvent.KIND,
|
||||
JobErrorEvent.KIND,
|
||||
HuddleStartedEvent.KIND,
|
||||
HuddleParticipantJoinedEvent.KIND,
|
||||
HuddleParticipantLeftEvent.KIND,
|
||||
HuddleEndedEvent.KIND,
|
||||
)
|
||||
|
||||
/** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */
|
||||
val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
|
||||
|
||||
/**
|
||||
* Kinds requested on the **open channel's live tail only** — the timeline set plus the
|
||||
* ephemeral kind-20002 typing indicator. Typing is scoped to the one channel on screen
|
||||
* (not the whole joined fleet) because it's a live "someone is typing" signal, never
|
||||
* stored (20000-29999) and never a feed row (`LocalCache` records it into `BuzzTypingState`
|
||||
* and drops it). It matches nothing on a vanilla relay.
|
||||
*/
|
||||
val RELAY_GROUP_OPEN_TAIL_KINDS = RELAY_GROUP_ALL_TIMELINE_KINDS + TypingIndicatorEvent.KIND
|
||||
|
||||
/** Forum-thread kinds shown in a group's Threads tab. */
|
||||
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
|
||||
|
||||
@@ -146,7 +217,7 @@ fun buildRelayGroupJoinedChatTailFilters(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
|
||||
since = sinceEpoch,
|
||||
),
|
||||
@@ -162,7 +233,7 @@ fun buildRelayGroupOpenChatTailFilter(
|
||||
relay = groupId.relayUrl,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
kinds = RELAY_GROUP_OPEN_TAIL_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
since = sinceEpoch,
|
||||
),
|
||||
@@ -185,7 +256,7 @@ fun buildRelayGroupHistoryFilters(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_TIMELINE_KINDS,
|
||||
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
|
||||
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
|
||||
until = until,
|
||||
limit = limit,
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
@@ -68,6 +69,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaA
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||
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.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
@@ -85,6 +91,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
@@ -156,6 +163,11 @@ open class ChannelNewMessageViewModel :
|
||||
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
|
||||
val replyMode = mutableStateOf(ReplyMode.INLINE)
|
||||
|
||||
// When set, the composer is editing an existing Buzz stream message (kind 40002):
|
||||
// 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).
|
||||
val editingBuzzMessage = mutableStateOf<Note?>(null)
|
||||
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
@@ -264,6 +276,26 @@ open class ChannelNewMessageViewModel :
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks
|
||||
* the next send as a kind-40003 edit of it. Editing and replying are mutually
|
||||
* exclusive, so any pending reply is cleared. The caller gates this to the user's own
|
||||
* kind-40002 messages on a Buzz relay.
|
||||
*/
|
||||
fun editBuzzMessage(note: Note) {
|
||||
replyTo.value = null
|
||||
replyMode.value = ReplyMode.INLINE
|
||||
editingBuzzMessage.value = note
|
||||
message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "")
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun clearBuzzEdit() {
|
||||
editingBuzzMessage.value = null
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
open fun editFromDraft(draft: Note) {
|
||||
val noteEvent = draft.event
|
||||
val noteAuthor = draft.author
|
||||
@@ -525,7 +557,12 @@ open class ChannelNewMessageViewModel :
|
||||
// channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/
|
||||
// hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message,
|
||||
// not the raw text — so replying in a thread never silently drops any of them.
|
||||
val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event
|
||||
// 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
|
||||
// by the relay AFTER the composer already cleared. Buzz replies always go
|
||||
// through the 40002 branch with its thread markers instead.
|
||||
val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl))
|
||||
val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event
|
||||
if (minichatParent != null) {
|
||||
return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) {
|
||||
if (channel is RelayGroupChannel) {
|
||||
@@ -676,6 +713,56 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
channel is RelayGroupChannel &&
|
||||
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
||||
editingBuzzMessage.value != null -> {
|
||||
// Buzz edit (kind 40003): replaces the text of an existing kind-40002 message.
|
||||
// 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
|
||||
// `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
|
||||
// re-renders with this content.
|
||||
val target = editingBuzzMessage.value!!
|
||||
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message)
|
||||
}
|
||||
|
||||
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
|
||||
// Buzz workspace message: the native kind is 40002 (stream message v2)
|
||||
// scoped with the group's `h` tag; Buzz threads replies with NIP-10
|
||||
// marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"],
|
||||
// collapsing to a single "reply" when the parent IS the root — mirrors
|
||||
// thread_tags in buzz-sdk builders.rs) plus a `p` notify to the parent
|
||||
// author. The dialect check comes from BuzzRelayDialect (marked off
|
||||
// verified Buzz events), not a channel subtype: channel instances are
|
||||
// captured by screens for their whole life, so the dialect must be
|
||||
// able to flip mid-session without swapping objects.
|
||||
StreamMessageV2Event.build(channel.groupId.id, tagger.message) {
|
||||
replyTo.value?.let { parent ->
|
||||
// The parent's root marker (when it is itself a nested reply),
|
||||
// else the parent's OWN reply target (a direct reply's collapsed
|
||||
// form carries the root as its "reply" marker — Buzz's relay
|
||||
// validates ancestry and rejects a mis-derived root), else the
|
||||
// parent starts the thread.
|
||||
val parentTags = parent.event?.tags
|
||||
val root =
|
||||
parentTags?.buzzThreadRoot()
|
||||
?: parentTags?.buzzThreadReply()
|
||||
?: parent.idHex
|
||||
buzzThread(root, parent.idHex)
|
||||
parent.author?.pubkeyHex?.let { pTag(PTag(it)) }
|
||||
}
|
||||
|
||||
hashtags(findHashtags(tagger.message))
|
||||
references(findURLs(tagger.message))
|
||||
quotes(findNostrUris(tagger.message))
|
||||
contentWarningReason?.let { contentWarning(it) }
|
||||
localExpirationDate?.let { expiration(it) }
|
||||
|
||||
emojis(emojis)
|
||||
imetas(usedAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
channel is RelayGroupChannel -> {
|
||||
// NIP-29 group message: a kind-9 chat scoped to the group with an
|
||||
// `h` tag. The event is published only to the group's host relay
|
||||
@@ -729,6 +816,7 @@ open class ChannelNewMessageViewModel :
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
replyTo.value = null
|
||||
editingBuzzMessage.value = null
|
||||
|
||||
urlPreview = null
|
||||
|
||||
|
||||
@@ -22,21 +22,31 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
@@ -56,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
|
||||
@@ -87,6 +98,35 @@ fun EditFieldRow(
|
||||
)
|
||||
}
|
||||
|
||||
// Buzz edit mode: a banner reminding the user the next send replaces an existing
|
||||
// message (a kind-40003 edit), with an X to abandon the edit and clear the field.
|
||||
channelScreenModel.editingBuzzMessage.value?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_editing_banner),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f).padding(start = 8.dp),
|
||||
)
|
||||
IconButton(onClick = { channelScreenModel.clearBuzzEdit() }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Close,
|
||||
contentDescription = stringRes(R.string.cancel),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channelScreenModel.uploadState?.let { uploading ->
|
||||
uploading.multiOrchestrator?.let { _ ->
|
||||
ChannelFileUploadDialog(
|
||||
@@ -121,9 +161,28 @@ fun EditFieldRow(
|
||||
)
|
||||
}
|
||||
|
||||
// Client-side throttle for the Buzz kind-20002 typing heartbeat (below).
|
||||
val lastTypingSecs = remember { longArrayOf(0L) }
|
||||
|
||||
ThinPaddingTextField(
|
||||
state = channelScreenModel.message,
|
||||
onTextChanged = { channelScreenModel.onMessageChanged() },
|
||||
onTextChanged = {
|
||||
channelScreenModel.onMessageChanged()
|
||||
// Buzz typing indicator: fire a throttled heartbeat while composing in a
|
||||
// Buzz workspace channel, so other members see "… is typing". No-op on any
|
||||
// other chat (the kind is Buzz-only and the relay would ignore it anyway).
|
||||
val channel = channelScreenModel.channel
|
||||
if (channel is RelayGroupChannel &&
|
||||
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
|
||||
channelScreenModel.message.text.isNotEmpty()
|
||||
) {
|
||||
val now = TimeUtils.now()
|
||||
if (now - lastTypingSecs[0] >= BuzzTypingState.TYPING_HEARTBEAT_SECS) {
|
||||
lastTypingSecs[0] = now
|
||||
accountViewModel.sendBuzzTyping(channel)
|
||||
}
|
||||
}
|
||||
},
|
||||
onContentReceived = { uri, mimeType ->
|
||||
channelScreenModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
|
||||
},
|
||||
|
||||
@@ -104,6 +104,7 @@ fun buildSettingsCatalog(
|
||||
symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings),
|
||||
symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings),
|
||||
symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage),
|
||||
symEntry(R.string.agent_console_title, MaterialSymbols.AutoAwesome, R.string.agent_console_search_keywords, Route.AgentConsole),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1278,6 +1278,7 @@
|
||||
<string name="nest_create_submit">Start space</string>
|
||||
<string name="nest_create_schedule_toggle">Schedule for later</string>
|
||||
<string name="nest_create_when">Pick a start time</string>
|
||||
<string name="agent_console_title">Agent console</string>
|
||||
<string name="nests_servers_title">Nest servers</string>
|
||||
<string name="nests_servers_explainer">Choose which MoQ host servers Amethyst publishes your nests to. The first entry is used by default when you start a new space.</string>
|
||||
<string name="nests_servers_my_section">Your servers</string>
|
||||
@@ -2128,6 +2129,7 @@
|
||||
<string name="event_sync_search_keywords" translatable="false">negentropy, sync, reconcile, backfill</string>
|
||||
<string name="import_follows_search_keywords" translatable="false">contacts, follows, follow list, import</string>
|
||||
<string name="nests_servers_search_keywords" translatable="false">audio rooms, live, spaces, rooms</string>
|
||||
<string name="agent_console_search_keywords" translatable="false">buzz, agents, personas, cost, tokens, fleet, ai, turn metrics</string>
|
||||
<string name="profile_badges_search_keywords" translatable="false">badges, awards</string>
|
||||
<string name="favorite_dvms_search_keywords" translatable="false">dvm, data vending machine, algo, algorithm, feeds</string>
|
||||
<string name="reactions_search_keywords" translatable="false">emoji, like, reaction</string>
|
||||
@@ -3288,6 +3290,22 @@
|
||||
<string name="chat_system_created_channel">%1$s created the channel %2$s</string>
|
||||
<string name="chat_system_created_channel_unnamed">%1$s created the channel</string>
|
||||
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
|
||||
<string name="buzz_message_edited">(edited)</string>
|
||||
<string name="buzz_diff_truncated">(diff truncated)</string>
|
||||
<string name="buzz_canvas_title">Canvas</string>
|
||||
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
|
||||
<string name="buzz_edit_message">Edit</string>
|
||||
<string name="buzz_editing_banner">Editing message</string>
|
||||
<string name="buzz_typing_one">%1$s is typing…</string>
|
||||
<string name="buzz_typing_two">%1$s and %2$s are typing…</string>
|
||||
<string name="buzz_typing_many">Several people are typing…</string>
|
||||
<string name="buzz_workspaces_title">Workspaces</string>
|
||||
<string name="buzz_workspaces_section">Your workspaces</string>
|
||||
<string name="buzz_workspaces_empty_title">No workspaces yet</string>
|
||||
<string name="buzz_workspaces_empty_body">Buzz workspaces are relays where humans and AI agents build together. Join one to see it here.</string>
|
||||
<string name="buzz_workspaces_browse">Browse groups</string>
|
||||
<string name="buzz_console_card_title">Agent Console</string>
|
||||
<string name="buzz_console_card_subtitle">Costs · Personas · Live observer</string>
|
||||
|
||||
<string name="chat_delivery_details_title">Message Delivery</string>
|
||||
<string name="close">Close</string>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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.BuzzWorkspaceStates
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
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 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.assertNull
|
||||
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 off VERIFIED events,
|
||||
* timeline attachment into the group's (stable, never-swapped) `RelayGroupChannel`, and
|
||||
* the kind-40003 edit overlay held in `BuzzWorkspaceStates` keyed by the channel id.
|
||||
*/
|
||||
class BuzzWorkspaceChannelTest {
|
||||
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
|
||||
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()
|
||||
BuzzWorkspaceStates.clearForTesting()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(Looper::class)
|
||||
BuzzRelayDialect.clearForTesting()
|
||||
BuzzWorkspaceStates.clearForTesting()
|
||||
}
|
||||
|
||||
private fun newChannelId() = UUID.randomUUID().toString()
|
||||
|
||||
private suspend fun streamMessage(
|
||||
channelId: String,
|
||||
text: String,
|
||||
) = signer.sign(StreamMessageV2Event.build(channelId, text))
|
||||
|
||||
@Test
|
||||
fun verifiedBuzzKindMarksDialectAndAttachesToTheSameChannel() =
|
||||
runBlocking {
|
||||
val channelId = newChannelId()
|
||||
val msg = streamMessage(channelId, "hello workspace")
|
||||
|
||||
assertFalse(BuzzRelayDialect.isBuzz(buzzRelay))
|
||||
|
||||
LocalCache.checkDeletionAndConsume(msg, buzzRelay, false)
|
||||
|
||||
assertTrue("a verified 40002 marks the serving relay as Buzz", BuzzRelayDialect.isBuzz(buzzRelay))
|
||||
|
||||
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))
|
||||
assertNotNull("the group channel exists after a 40002 lands", channel)
|
||||
// The channel type never changes on dialect discovery — screens capture it for life.
|
||||
assertTrue(channel is RelayGroupChannel)
|
||||
assertTrue("the message is on the channel timeline", channel!!.notes.containsKey(msg.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelInstanceIsStableAcrossDialectDiscovery() =
|
||||
runBlocking {
|
||||
val channelId = newChannelId()
|
||||
val key = GroupId(channelId, buzzRelay)
|
||||
|
||||
// The group materializes plain (e.g. via 39000) BEFORE any Buzz kind reveals the dialect.
|
||||
val before = LocalCache.getOrCreateRelayGroupChannel(key)
|
||||
|
||||
LocalCache.checkDeletionAndConsume(streamMessage(channelId, "first buzz kind"), buzzRelay, false)
|
||||
|
||||
val after = LocalCache.getRelayGroupChannelIfExists(key)
|
||||
// Same object: no swap, so every live screen/feed/composer reference stays valid.
|
||||
assertTrue("dialect discovery must NOT swap the channel instance", before === after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unverifiedEventDoesNotMarkTheDialect() =
|
||||
runBlocking {
|
||||
val channelId = newChannelId()
|
||||
// Tamper the signature so verification fails.
|
||||
val real = streamMessage(channelId, "spoof")
|
||||
val forged = StreamMessageV2Event(real.id, real.pubKey, real.createdAt, real.tags, real.content, "00".repeat(64))
|
||||
|
||||
LocalCache.checkDeletionAndConsume(forged, buzzRelay, false)
|
||||
|
||||
assertFalse("an unverifiable event must not flip the dialect", BuzzRelayDialect.isBuzz(buzzRelay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editOverlayTracksNewestEditAndIsNotATimelineRow() =
|
||||
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),
|
||||
)
|
||||
|
||||
// Out-of-order arrival: newer first, older second.
|
||||
LocalCache.checkDeletionAndConsume(edit2, buzzRelay, false)
|
||||
LocalCache.checkDeletionAndConsume(edit1, buzzRelay, false)
|
||||
|
||||
val state = BuzzWorkspaceStates.getIfExists(channelId)!!
|
||||
assertEquals("newest edit wins regardless of arrival order", "the fix", state.effectiveContentFor(original.id))
|
||||
assertEquals(edit2.id, state.editFor(original.id)?.idHex)
|
||||
|
||||
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, buzzRelay))!!
|
||||
assertFalse("edits are overlays, never timeline rows", channel.notes.containsKey(edit1.id))
|
||||
assertFalse("edits are overlays, never timeline rows", channel.notes.containsKey(edit2.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlayIsKeyedByChannelIdSoOwnSendsWithNoRelayLand() =
|
||||
runBlocking {
|
||||
// An edit consumed with a null provenance relay (own optimistic send) must
|
||||
// still record its overlay — the registry is keyed by channel id, not relay.
|
||||
val channelId = newChannelId()
|
||||
val original = streamMessage(channelId, "original")
|
||||
LocalCache.checkDeletionAndConsume(original, null, true)
|
||||
|
||||
val edit =
|
||||
signer.sign(
|
||||
StreamMessageEditEvent.build(channelId, original.id, "edited offline", createdAt = original.createdAt + 5),
|
||||
)
|
||||
LocalCache.checkDeletionAndConsume(edit, null, true)
|
||||
|
||||
assertEquals("edited offline", BuzzWorkspaceStates.getIfExists(channelId)?.effectiveContentFor(original.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pruneDropsOverlaysForMessagesNoLongerInTheChannel() =
|
||||
runBlocking {
|
||||
val channelId = newChannelId()
|
||||
val original = streamMessage(channelId, "will be pruned")
|
||||
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
|
||||
val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "edit", createdAt = original.createdAt + 5))
|
||||
LocalCache.checkDeletionAndConsume(edit, buzzRelay, false)
|
||||
|
||||
val state = BuzzWorkspaceStates.getIfExists(channelId)!!
|
||||
assertNotNull(state.editFor(original.id))
|
||||
|
||||
// Simulate the message having been reaped from the channel.
|
||||
state.pruneEdits(emptySet())
|
||||
assertNull("overlay for a pruned message must be dropped", state.editFor(original.id))
|
||||
}
|
||||
}
|
||||
@@ -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.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Every relay-group timeline REQ carries the Buzz stream kinds unconditionally (they
|
||||
* simply match nothing on a vanilla relay); gating them created a history-cursor bug.
|
||||
*/
|
||||
class BuzzTimelineKindsTest {
|
||||
private val buzzRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!!
|
||||
private val vanillaRelay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example.com/")!!
|
||||
|
||||
@Test
|
||||
fun theUnifiedTimelineKindSetContainsBothNip29AndBuzz() {
|
||||
assertTrue(RELAY_GROUP_ALL_TIMELINE_KINDS.containsAll(RELAY_GROUP_TIMELINE_KINDS))
|
||||
assertTrue(RELAY_GROUP_ALL_TIMELINE_KINDS.containsAll(BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyTimelineReqCarriesBuzzKindsUnconditionally() {
|
||||
// The Buzz kinds are requested on EVERY relay-group timeline REQ (open tail,
|
||||
// joined fleet tail), independent of any dialect mark. Gating them created a
|
||||
// history-cursor-skip bug — this pins the unconditional contract.
|
||||
val openTail = buildRelayGroupOpenChatTailFilter(GroupId("g1", vanillaRelay), sinceEpoch = 0L)
|
||||
assertTrue(openTail.filter.kinds!!.contains(StreamMessageV2Event.KIND))
|
||||
|
||||
val fleet =
|
||||
buildRelayGroupJoinedChatTailFilters(
|
||||
listOf(GroupTag("g1", buzzRelay.url), GroupTag("g2", vanillaRelay.url)),
|
||||
sinceEpoch = 0L,
|
||||
)
|
||||
fleet.forEach { assertTrue(it.filter.kinds!!.contains(StreamMessageV2Event.KIND)) }
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,9 @@ class RelayGroupFilterBuildersTest {
|
||||
assertEquals(2, filters.size)
|
||||
|
||||
val a = filters.single { it.relay == relayA }
|
||||
assertEquals(timelineKinds, a.filter.kinds)
|
||||
// The joined tail now always carries the Buzz timeline kinds too (see
|
||||
// RELAY_GROUP_ALL_TIMELINE_KINDS / BuzzTimelineKindsTest).
|
||||
assertEquals(timelineKinds + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, a.filter.kinds)
|
||||
assertEquals(setOf("g1", "g2"), a.filter.tags!!["h"]!!.toSet())
|
||||
assertEquals(999L, a.filter.since)
|
||||
assertNull("a time-floored tail must NOT cap by count (that is what lets it batch)", a.filter.limit)
|
||||
@@ -126,7 +128,11 @@ class RelayGroupFilterBuildersTest {
|
||||
fun `open chat tail is a single host h-filter with since only`() {
|
||||
val f = buildRelayGroupOpenChatTailFilter(g1OnA, 5L)
|
||||
assertEquals(relayA, f.relay)
|
||||
assertEquals(timelineKinds, f.filter.kinds)
|
||||
// The open-channel tail carries the full Buzz timeline set PLUS the ephemeral
|
||||
// kind-20002 typing indicator (RELAY_GROUP_OPEN_TAIL_KINDS) — typing is scoped to
|
||||
// the one channel on screen, not the joined fleet. It is also the Buzz-dialect
|
||||
// detection bootstrap. Harmless on vanilla relays.
|
||||
assertEquals(RELAY_GROUP_OPEN_TAIL_KINDS, f.filter.kinds)
|
||||
assertEquals(listOf("g1"), f.filter.tags!!["h"])
|
||||
assertEquals(5L, f.filter.since)
|
||||
assertNull(f.filter.until)
|
||||
|
||||
@@ -591,6 +591,22 @@ screen speaks.
|
||||
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
|
||||
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
|
||||
|
||||
### Buzz workspaces (block/buzz — NIP-29 dialect)
|
||||
|
||||
[`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
|
||||
surface: the kind:40002 stream message, the owner-attestation primitive (NIP-OA), and the
|
||||
agent console (turn-metric aggregation + personas), all driving the same `quartz` models
|
||||
and `commons` aggregator the app uses.
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `amy buzz post RELAY GID <text>` | Post a kind:40002 stream message (Buzz-native) into a workspace. |
|
||||
| `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 console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. |
|
||||
| `amy buzz personas [--relays R,R] [--timeout SECS]` | List my kind:30175 persona definitions (newest per slug). |
|
||||
|
||||
### Concord Channels (encrypted communities)
|
||||
|
||||
Encrypted, serverless communities (the CORD specs). Community secrets
|
||||
|
||||
@@ -59,6 +59,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| NIP-18 reposts / quotes | 🆕 | |
|
||||
| NIP-25 reactions | ✅ in groups · 🆕 elsewhere | `marmot message react` covers MLS group reactions; outer-event reactions still pending. |
|
||||
| NIP-29 relay groups (`amy relaygroup`) | ✅ | `RelayGroupCommands` — list/browse/info/create/join/leave/message/edit/invite/put-user/remove-user against a host relay; kind:10009 joined-list kept in sync. |
|
||||
| Buzz workspaces (`amy buzz`) | ✅ | `BuzzCommands` — post/read the kind:40002 stream timeline, `attest` (offline NIP-OA), `console` (decrypt+aggregate kind:44200 turn metrics via the shared `AgentFleetAggregator`), `personas` (kind:30175). Join/leave reuse `amy relaygroup` (Buzz workspaces are NIP-29 groups). |
|
||||
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
|
||||
| NIP-57 zaps (send) | ✅ partial | `ZapCommand` — `zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. |
|
||||
| NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. |
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.commands.AdminCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.BlossomCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.BunkerCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.BuzzCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.ConcordCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.CountCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.CreateCommand
|
||||
@@ -285,6 +286,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"relay" -> RelayCommands.dispatch(dataDir, tail)
|
||||
"marmot" -> marmotDispatch(dataDir, tail)
|
||||
"relaygroup" -> RelayGroupCommands.dispatch(dataDir, tail)
|
||||
"buzz" -> BuzzCommands.dispatch(dataDir, tail)
|
||||
"dm" -> DmCommands.dispatch(dataDir, tail)
|
||||
"geochat" -> GeochatCommands.dispatch(dataDir, tail)
|
||||
"profile" -> ProfileCommands.dispatch(dataDir, tail)
|
||||
@@ -802,6 +804,13 @@ private fun printUsage() {
|
||||
| [--role admin|moderator]
|
||||
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
||||
|
|
||||
|Buzz (block/buzz agent workspaces — NIP-29 dialect):
|
||||
| buzz post RELAY GID <text> post a kind-40002 stream message
|
||||
| buzz read RELAY GID [--limit N] read recent workspace messages
|
||||
| buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline)
|
||||
| buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics
|
||||
| buzz personas [--relays R,R] list my kind-30175 personas
|
||||
|
|
||||
|Marmot (MLS group messaging):
|
||||
| marmot key-package publish publish a fresh KeyPackage
|
||||
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* 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.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
|
||||
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
|
||||
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/**
|
||||
* `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
|
||||
* 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),
|
||||
* and the agent console (turn-metric aggregation + personas).
|
||||
*/
|
||||
object BuzzCommands {
|
||||
private val USAGE =
|
||||
"""
|
||||
|amy buzz post RELAY GID <text> post a kind-40002 stream message
|
||||
|amy buzz read RELAY GID [--limit N] read recent workspace messages (9/40002/40099)
|
||||
| [--timeout SECS]
|
||||
|amy buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline; prints the auth tag)
|
||||
| [--after UNIX] [--before UNIX]
|
||||
|amy buzz console [--relays R,R] decrypt + aggregate my kind-44200 turn metrics
|
||||
| [--timeout SECS]
|
||||
|amy buzz personas [--relays R,R] list my kind-30175 personas
|
||||
| [--timeout SECS]
|
||||
""".trimMargin()
|
||||
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
route(
|
||||
"buzz",
|
||||
tail,
|
||||
USAGE,
|
||||
mapOf(
|
||||
"post" to { rest -> post(dataDir, rest) },
|
||||
"read" to { rest -> read(dataDir, rest) },
|
||||
"attest" to { rest -> attest(dataDir, rest) },
|
||||
"console" to { rest -> console(dataDir, rest) },
|
||||
"personas" to { rest -> personas(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
/** `buzz post RELAY GID <text>` → publishes a kind-40002 stream message with an `h` tag. */
|
||||
private suspend fun post(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val usage = "buzz post RELAY GID <text>"
|
||||
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")
|
||||
return publishScoped(dataDir, rest, usage) { _, groupId, _ ->
|
||||
StreamMessageV2Event.build(groupId, text)
|
||||
}
|
||||
}
|
||||
|
||||
/** `buzz read RELAY GID [--limit N] [--timeout SECS]` → drains the recent human-visible timeline. */
|
||||
private suspend fun read(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "buzz read RELAY GID [--limit N] [--timeout SECS]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val limit = args.flag("limit")?.toIntOrNull() ?: 50
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
|
||||
args.rejectUnknown("limit", "timeout")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(ChatEvent.KIND, StreamMessageV2Event.KIND, SystemMessageEvent.KIND),
|
||||
tags = mapOf("h" to listOf(groupId)),
|
||||
limit = limit,
|
||||
)
|
||||
val messages =
|
||||
ctx
|
||||
.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.createdAt }
|
||||
.take(limit)
|
||||
.map {
|
||||
mapOf(
|
||||
"id" to it.id,
|
||||
"kind" to it.kind,
|
||||
"author" to it.pubKey,
|
||||
"created_at" to it.createdAt,
|
||||
"content" to it.content,
|
||||
)
|
||||
}
|
||||
Output.emit(mapOf("group_id" to groupId, "relay" to relay.url, "count" to messages.size, "messages" to messages))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `buzz attest AGENT [--kind K] [--after T] [--before T]` → signs a NIP-OA
|
||||
* [OwnerAttestation] authorizing AGENT and prints the `auth` tag. Offline (the
|
||||
* signature covers a hashed commitment, not an event) and needs a local private key.
|
||||
*/
|
||||
private suspend fun attest(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]"
|
||||
val agentInput = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val agentHex =
|
||||
decodePublicKeyAsHexOrNull(agentInput.trim())?.takeIf { it.isValid() }
|
||||
?: return Output.error("bad_args", "invalid agent public key (npub or 64-char hex): $agentInput")
|
||||
val kind = args.flag("kind")?.let { it.toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer") }
|
||||
val after = args.flag("after")?.let { it.toLongOrNull() ?: return Output.error("bad_args", "after must be a unix time") }
|
||||
val before = args.flag("before")?.let { it.toLongOrNull() ?: return Output.error("bad_args", "before must be a unix time") }
|
||||
args.rejectUnknown("kind", "after", "before")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
val keyPair = ctx.identity.keyPair()
|
||||
if (keyPair.privKey == null) return Output.error("no_private_key", "attestation signing needs a local private key")
|
||||
val conditions = AttestationConditions(kind = kind, createdAtBefore = before, createdAtAfter = after)
|
||||
val attestation =
|
||||
try {
|
||||
OwnerAttestation.sign(agentHex, conditions, keyPair)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return Output.error("bad_args", e.message ?: "could not sign the attestation")
|
||||
}
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"agent" to agentHex,
|
||||
"owner" to attestation.ownerPubKey,
|
||||
"conditions" to attestation.conditions.ifEmpty { null },
|
||||
"sig" to attestation.sig,
|
||||
"auth_tag" to attestation.toTag().toList(),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `buzz console [--relays R,R] [--timeout SECS]` → fetches my kind-44200 turn metrics
|
||||
* (`#p` = me), NIP-44-decrypts them, and aggregates fleet + per-agent cost via the same
|
||||
* [AgentFleetAggregator] the app's Agent Console uses.
|
||||
*/
|
||||
private suspend fun console(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relaysFlag = args.flag("relays")
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 15
|
||||
args.rejectUnknown("relays", "timeout")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val me = ctx.identity.pubKeyHex
|
||||
val relays = relaysFor(ctx, relaysFlag)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
|
||||
|
||||
val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me)))
|
||||
val decrypted =
|
||||
ctx
|
||||
.drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<AgentTurnMetricEvent>()
|
||||
.distinctBy { it.id }
|
||||
.mapNotNull { e -> e.decryptOrNull(ctx.signer)?.let { (e.agentPubKey() ?: e.pubKey) to it } }
|
||||
val metrics = AgentFleetAggregator.aggregate(decrypted)
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"total_cost_usd" to metrics.totals.costUsd,
|
||||
"total_tokens" to metrics.totals.totalTokens,
|
||||
"input_tokens" to metrics.totals.inputTokens,
|
||||
"output_tokens" to metrics.totals.outputTokens,
|
||||
"turns" to metrics.totalTurns,
|
||||
"sessions" to metrics.totalSessions,
|
||||
"agents" to metrics.agents.size,
|
||||
"estimated" to metrics.hasUnreliableEstimates,
|
||||
"breakdown" to
|
||||
metrics.agents.map {
|
||||
mapOf(
|
||||
"agent" to it.agentPubKey,
|
||||
"cost_usd" to it.totals.costUsd,
|
||||
"tokens" to it.totals.totalTokens,
|
||||
"turns" to it.turns,
|
||||
"sessions" to it.sessions,
|
||||
"models" to it.models.sorted(),
|
||||
"last_activity" to it.lastActivity,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `buzz personas [--relays R,R] [--timeout SECS]` → lists my kind-30175 persona definitions. */
|
||||
private suspend fun personas(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relaysFlag = args.flag("relays")
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 15
|
||||
args.rejectUnknown("relays", "timeout")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val me = ctx.identity.pubKeyHex
|
||||
val relays = relaysFor(ctx, relaysFlag)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
|
||||
|
||||
val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me))
|
||||
val personas =
|
||||
ctx
|
||||
.drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<PersonaEvent>()
|
||||
// Newest per addressable slug (replaceable): keep the latest for each d tag.
|
||||
.groupBy { it.slug() }
|
||||
.values
|
||||
.mapNotNull { versions -> versions.maxByOrNull { it.createdAt } }
|
||||
.sortedBy { it.personaOrNull()?.displayName ?: it.slug() ?: "" }
|
||||
.map {
|
||||
val content = it.personaOrNull()
|
||||
mapOf(
|
||||
"slug" to it.slug(),
|
||||
"display_name" to content?.displayName,
|
||||
"model" to content?.model,
|
||||
"runtime" to content?.runtime,
|
||||
"provider" to content?.provider,
|
||||
)
|
||||
}
|
||||
Output.emit(mapOf("count" to personas.size, "personas" to personas))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The `--relays` set if given, else the account's outbox relays. */
|
||||
private suspend fun relaysFor(
|
||||
ctx: Context,
|
||||
relaysFlag: String?,
|
||||
) = relaysFlag
|
||||
?.split(",")
|
||||
?.mapNotNull { normalizeGroupRelay(it) }
|
||||
?.toSet()
|
||||
?: ctx.outboxRelays()
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Holds the NIP-OA [OwnerAttestation]s this device has *received* — one owner-signed
|
||||
* authorization per agent key that lets that key publish in the owner's Buzz workspace
|
||||
* without being enrolled as a relay member.
|
||||
*
|
||||
* The counterpart of issuance ([OwnerAttestation] is signed by an owner and handed to an
|
||||
* agent operator out-of-band): when the account whose pubkey equals a stored key
|
||||
* authenticates (NIP-42) to a Buzz-dialect relay, the auth coordinator attaches the
|
||||
* matching [OwnerAttestation.toTag] to the AUTH event, and the relay grants virtual
|
||||
* membership while the owner stays a member.
|
||||
*
|
||||
* Keyed by the **agent** pubkey (the key the attestation authorizes). Only a
|
||||
* [OwnerAttestation.verify]-passing attestation for that key should be stored, so the
|
||||
* store never carries a credential the relay would reject.
|
||||
*
|
||||
* Like [BuzzRelayDialect] this is a process-wide singleton, and — for now — in-memory
|
||||
* only: a held attestation is re-pasted after a process restart. Persisting it across
|
||||
* launches (per-account, encrypted) is a follow-up.
|
||||
*/
|
||||
object BuzzHeldAttestations {
|
||||
private val heldByAgent = MutableStateFlow<Map<HexKey, OwnerAttestation>>(emptyMap())
|
||||
|
||||
/** All held attestations, keyed by agent pubkey; UI can collect this. */
|
||||
val flow: StateFlow<Map<HexKey, OwnerAttestation>> = heldByAgent
|
||||
|
||||
/** The attestation held for [agentPubKey], or null. */
|
||||
fun attestationFor(agentPubKey: HexKey): OwnerAttestation? = heldByAgent.value[agentPubKey]
|
||||
|
||||
/**
|
||||
* The `auth` tag to attach to [agentPubKey]'s NIP-42 AUTH event, or null when no
|
||||
* verified attestation is held for that key.
|
||||
*/
|
||||
fun authTagFor(agentPubKey: HexKey): Array<String>? = attestationFor(agentPubKey)?.toTag()
|
||||
|
||||
/**
|
||||
* Stores [attestation] as authorizing [agentPubKey]. The caller must have already
|
||||
* confirmed `attestation.verify(agentPubKey)`; this is a CAS-loop put so concurrent
|
||||
* writers don't clobber each other.
|
||||
*/
|
||||
fun put(
|
||||
agentPubKey: HexKey,
|
||||
attestation: OwnerAttestation,
|
||||
) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (current[agentPubKey] == attestation) return
|
||||
if (heldByAgent.compareAndSet(current, current + (agentPubKey to attestation))) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes any attestation held for [agentPubKey]. */
|
||||
fun remove(agentPubKey: HexKey) {
|
||||
while (true) {
|
||||
val current = heldByAgent.value
|
||||
if (agentPubKey !in current) return
|
||||
if (heldByAgent.compareAndSet(current, current - agentPubKey)) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clears all held attestations so unit tests don't leak state. */
|
||||
fun clearForTesting() {
|
||||
heldByAgent.value = emptyMap()
|
||||
}
|
||||
}
|
||||
@@ -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,84 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Live "who is typing" state for Buzz workspace channels, fed by the ephemeral
|
||||
* kind-20002 typing indicator (`TypingIndicatorEvent`). Buzz relays never store these
|
||||
* (20000-29999), so this is pure in-memory live state — the exact analog of
|
||||
* `ConcordCommunitySession.typing`, but keyed by the channel's `h` UUID because Buzz
|
||||
* channels are relay-authoritative NIP-29 groups rather than encrypted planes.
|
||||
*
|
||||
* Shape: `channelId -> (typistPubKey -> lastHeartbeatUnixSecs)`. A typist is "active"
|
||||
* while their last heartbeat is within [TYPING_STALE_SECS]; the UI ages them out on a
|
||||
* timer. Mutations are lock-guarded because `LocalCache` consume runs on several relay
|
||||
* reader threads and an unsynchronized check-then-act could drop a fresh heartbeat.
|
||||
*
|
||||
* Process-wide singleton like [BuzzRelayDialect] / `BuzzWorkspaceStates`.
|
||||
*/
|
||||
object BuzzTypingState {
|
||||
private val lock = KmpLock()
|
||||
private val typingByChannel = HashMap<String, HashMap<HexKey, Long>>()
|
||||
private val mutableTyping = MutableStateFlow<Map<String, Map<HexKey, Long>>>(emptyMap())
|
||||
|
||||
/** `channelId -> (typist -> lastHeartbeatSecs)`; the UI collects this and ages entries out. */
|
||||
val flow: StateFlow<Map<String, Map<HexKey, Long>>> = mutableTyping
|
||||
|
||||
/**
|
||||
* Records a typing heartbeat from [typist] in [channelId] at [atSecs] (clamped to
|
||||
* [nowSecs] so a future-dated peer can't wedge the freshness window), then prunes
|
||||
* anything already stale. No-op for a stale-on-arrival heartbeat.
|
||||
*/
|
||||
fun record(
|
||||
channelId: String,
|
||||
typist: HexKey,
|
||||
atSecs: Long,
|
||||
nowSecs: Long,
|
||||
) = lock.withLock {
|
||||
val stamp = minOf(atSecs, nowSecs)
|
||||
if (nowSecs - stamp > TYPING_STALE_SECS) return@withLock
|
||||
val perChannel = typingByChannel.getOrPut(channelId) { HashMap() }
|
||||
val prev = perChannel[typist]
|
||||
if (prev == null || stamp > prev) perChannel[typist] = stamp
|
||||
perChannel.entries.retainAll { nowSecs - it.value <= TYPING_STALE_SECS }
|
||||
if (perChannel.isEmpty()) typingByChannel.remove(channelId)
|
||||
mutableTyping.value = typingByChannel.mapValues { it.value.toMap() }
|
||||
}
|
||||
|
||||
/** Test-only: clears all typing state so unit tests don't leak into each other. */
|
||||
fun clearForTesting() =
|
||||
lock.withLock {
|
||||
typingByChannel.clear()
|
||||
mutableTyping.value = emptyMap()
|
||||
}
|
||||
|
||||
/** A peer must heartbeat at least this often to stay "typing"; also the UI fade window. */
|
||||
const val TYPING_STALE_SECS = 8L
|
||||
|
||||
/** Client-side throttle: send at most one typing heartbeat this often while composing. */
|
||||
const val TYPING_HEARTBEAT_SECS = 4L
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Buzz-only overlay state for one workspace channel: the kind-40003 edit overlay
|
||||
* (newest edit per message — rendering the original without it shows stale text as
|
||||
* current) and the newest kind-40100 canvas.
|
||||
*
|
||||
* This lives OUTSIDE the channel object on purpose. Screens, feed filters, and
|
||||
* composers capture their `RelayGroupChannel` instance once and hold it for the whole
|
||||
* session, so a channel can never be swapped for a "Buzz-typed" replacement when the
|
||||
* dialect is discovered mid-session — every live reference would keep rendering the
|
||||
* orphaned instance. Keeping the overlay in a registry keyed by the channel id makes
|
||||
* dialect discovery a non-event for object identity.
|
||||
*
|
||||
* All mutations are guarded by a per-state lock: consume runs on multiple relay
|
||||
* dispatcher threads, and unsynchronized check-then-act would let an older edit
|
||||
* overwrite a newer one.
|
||||
*/
|
||||
class BuzzWorkspaceState {
|
||||
private val lock = KmpLock()
|
||||
private val editsByTarget = LargeCache<HexKey, Note>()
|
||||
private val editVersion = MutableStateFlow(0)
|
||||
|
||||
/** Bumps when any overlay entry changes, so rows 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
|
||||
|
||||
private val canvasVersion = MutableStateFlow(0)
|
||||
|
||||
/** Bumps when [canvasNote] is replaced by a newer revision, so a canvas view re-reads it. */
|
||||
val canvasUpdates: StateFlow<Int> = canvasVersion
|
||||
|
||||
/** Records a 40003 edit; keeps only the newest per target (last-write-wins by created_at). */
|
||||
fun addEdit(
|
||||
targetId: HexKey,
|
||||
editNote: Note,
|
||||
) = lock.withLock {
|
||||
val current = editsByTarget.get(targetId)
|
||||
if (current == null || (editNote.createdAt() ?: 0L) > (current.createdAt() ?: 0L)) {
|
||||
editsByTarget.put(targetId, editNote)
|
||||
editVersion.value = editVersion.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) =
|
||||
lock.withLock {
|
||||
if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) {
|
||||
canvasNote = note
|
||||
canvasVersion.value = canvasVersion.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops overlay entries whose target message id is not in [aliveTargetIds] (memory pruning). */
|
||||
fun pruneEdits(aliveTargetIds: Set<HexKey>) =
|
||||
lock.withLock {
|
||||
val dead = editsByTarget.keys().filter { it !in aliveTargetIds }
|
||||
if (dead.isNotEmpty()) {
|
||||
dead.forEach { editsByTarget.remove(it) }
|
||||
editVersion.value = editVersion.value + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of [BuzzWorkspaceState] keyed by the channel's `h` id. Buzz channel ids are
|
||||
* relay-generated UUIDs (NIP-11 `h_grammar: uuid-v4-lowercase`), so the id alone is a
|
||||
* sound key — no relay scoping needed, which also lets own-authored edits (consumed
|
||||
* with no provenance relay) land in the right state.
|
||||
*
|
||||
* Like `LocalCache`, a process-wide singleton.
|
||||
*/
|
||||
object BuzzWorkspaceStates {
|
||||
private val states = LargeCache<String, BuzzWorkspaceState>()
|
||||
|
||||
fun getOrCreate(channelId: String): BuzzWorkspaceState = states.getOrCreate(channelId) { BuzzWorkspaceState() }
|
||||
|
||||
fun getIfExists(channelId: String): BuzzWorkspaceState? = states.get(channelId)
|
||||
|
||||
/** Test-only: clears all state so unit tests don't leak into each other. */
|
||||
fun clearForTesting() {
|
||||
states.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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.buzz.amTurnMetrics.AgentTurnMetricPayload
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.TokenCounts
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AgentFleetAggregatorTest {
|
||||
private val agentA = "a".repeat(64)
|
||||
private val agentB = "b".repeat(64)
|
||||
|
||||
private fun turn(
|
||||
agent: String,
|
||||
session: String?,
|
||||
seq: Long,
|
||||
turn: TokenCounts? = null,
|
||||
cumulative: TokenCounts? = null,
|
||||
deltaReliable: Boolean = true,
|
||||
ts: String = "2026-07-0${seq}T00:00:00Z",
|
||||
harness: String = "goose",
|
||||
model: String? = "claude",
|
||||
) = agent to
|
||||
AgentTurnMetricPayload(
|
||||
harness = harness,
|
||||
timestamp = ts,
|
||||
model = model,
|
||||
sessionId = session,
|
||||
turnId = "turn-$seq",
|
||||
turnSeq = seq,
|
||||
turn = turn,
|
||||
cumulative = cumulative,
|
||||
deltaReliable = deltaReliable,
|
||||
)
|
||||
|
||||
private fun counts(
|
||||
input: Long? = null,
|
||||
output: Long? = null,
|
||||
total: Long? = null,
|
||||
cost: Double? = null,
|
||||
) = TokenCounts(inputTokens = input, outputTokens = output, totalTokens = total, costUsd = cost)
|
||||
|
||||
@Test
|
||||
fun emptyYieldsEmpty() {
|
||||
assertEquals(AgentFleetMetrics.EMPTY, AgentFleetAggregator.aggregate(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cumulativeIsTheSessionTotalNotTheSumOfCumulatives() {
|
||||
// Three turns of one session; cumulative is the running total. Summing the
|
||||
// cumulatives (100+250+400) would triple-count — the total is the LAST one, 400.
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, cumulative = counts(total = 100, cost = 0.10)),
|
||||
turn(agentA, "s1", 2, cumulative = counts(total = 250, cost = 0.25)),
|
||||
turn(agentA, "s1", 3, cumulative = counts(total = 400, cost = 0.40)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(400L, metrics.totals.totalTokens)
|
||||
assertEquals(0.40, metrics.totals.costUsd, 1e-9)
|
||||
assertEquals(3, metrics.totalTurns)
|
||||
assertEquals(1, metrics.totalSessions)
|
||||
assertFalse(metrics.hasUnreliableEstimates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingMiddleTurnStillGivesTheRightSessionTotal() {
|
||||
// Turn 2 never arrived; cumulative-max still recovers the true session total.
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, cumulative = counts(total = 100)),
|
||||
turn(agentA, "s1", 3, cumulative = counts(total = 400)),
|
||||
),
|
||||
)
|
||||
assertEquals(400L, metrics.totals.totalTokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withoutCumulativeItSumsTheTurnDeltas() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, turn = counts(input = 10, output = 5, total = 15, cost = 0.01)),
|
||||
turn(agentA, "s1", 2, turn = counts(input = 20, output = 8, total = 28, cost = 0.02)),
|
||||
),
|
||||
)
|
||||
assertEquals(30L, metrics.totals.inputTokens)
|
||||
assertEquals(13L, metrics.totals.outputTokens)
|
||||
assertEquals(43L, metrics.totals.totalTokens)
|
||||
assertEquals(0.03, metrics.totals.costUsd, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perFieldFallbackTakesCostFromDeltasWhenCumulativeOmitsIt() {
|
||||
// cumulative reports tokens but not cost; cost must still come from the deltas.
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, turn = counts(cost = 0.05), cumulative = counts(total = 100)),
|
||||
turn(agentA, "s1", 2, turn = counts(cost = 0.07), cumulative = counts(total = 200)),
|
||||
),
|
||||
)
|
||||
assertEquals(200L, metrics.totals.totalTokens) // from cumulative max
|
||||
assertEquals(0.12, metrics.totals.costUsd, 1e-9) // from summed deltas
|
||||
}
|
||||
|
||||
@Test
|
||||
fun distinctSessionsSumTogether() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, cumulative = counts(total = 100, cost = 0.10)),
|
||||
turn(agentA, "s2", 1, cumulative = counts(total = 300, cost = 0.30)),
|
||||
),
|
||||
)
|
||||
assertEquals(400L, metrics.totals.totalTokens)
|
||||
assertEquals(0.40, metrics.totals.costUsd, 1e-9)
|
||||
assertEquals(2, metrics.totalSessions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun turnsWithNoSessionIdAreSeparateSingletons() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, null, 1, cumulative = counts(total = 100)),
|
||||
turn(agentA, null, 2, cumulative = counts(total = 100)),
|
||||
),
|
||||
)
|
||||
// Two sessionless turns must NOT collapse into one session (that would drop one).
|
||||
assertEquals(2, metrics.totalSessions)
|
||||
assertEquals(200L, metrics.totals.totalTokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perAgentBreakdownSortedByCostDescending() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, cumulative = counts(total = 100, cost = 0.10), model = "haiku"),
|
||||
turn(agentB, "s2", 1, cumulative = counts(total = 900, cost = 0.90), model = "opus"),
|
||||
),
|
||||
)
|
||||
assertEquals(2, metrics.agents.size)
|
||||
assertEquals(agentB, metrics.agents[0].agentPubKey) // pricier agent first
|
||||
assertEquals(0.90, metrics.agents[0].totals.costUsd, 1e-9)
|
||||
assertEquals(setOf("opus"), metrics.agents[0].models)
|
||||
assertEquals("2026-07-01T00:00:00Z", metrics.agents[1].lastActivity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreliableDeltaWithoutCumulativeFlagsEstimate() {
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, turn = counts(total = 50, cost = 0.05), deltaReliable = false),
|
||||
),
|
||||
)
|
||||
assertEquals(50L, metrics.totals.totalTokens)
|
||||
assertTrue(metrics.hasUnreliableEstimates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cumulativePresenceMakesUnreliableDeltaIrrelevant() {
|
||||
// deltaReliable=false but cumulative is present → we use cumulative, no estimate flag.
|
||||
val metrics =
|
||||
AgentFleetAggregator.aggregate(
|
||||
listOf(
|
||||
turn(agentA, "s1", 1, turn = counts(total = 999), cumulative = counts(total = 50), deltaReliable = false),
|
||||
),
|
||||
)
|
||||
assertEquals(50L, metrics.totals.totalTokens)
|
||||
assertFalse(metrics.hasUnreliableEstimates)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.buzz.oaOwnerAttestation.OwnerAttestation
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class BuzzHeldAttestationsTest {
|
||||
private val agent = "a".repeat(64)
|
||||
private val owner = "b".repeat(64)
|
||||
private val attestation = OwnerAttestation(ownerPubKey = owner, conditions = "kind=40002", sig = "c".repeat(128))
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() = BuzzHeldAttestations.clearForTesting()
|
||||
|
||||
@Test
|
||||
fun emptyStoreYieldsNoTag() {
|
||||
assertNull(BuzzHeldAttestations.attestationFor(agent))
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun heldAttestationSurfacesAsItsAuthTag() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertEquals(attestation, BuzzHeldAttestations.attestationFor(agent))
|
||||
// The tag attached to the agent's AUTH is exactly the attestation's ["auth", …] tag.
|
||||
assertContentEquals(attestation.toTag(), BuzzHeldAttestations.authTagFor(agent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeClearsTheHeldAttestation() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
BuzzHeldAttestations.remove(agent)
|
||||
assertNull(BuzzHeldAttestations.authTagFor(agent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneAgentsAttestationDoesNotLeakToAnother() {
|
||||
BuzzHeldAttestations.put(agent, attestation)
|
||||
assertNull(BuzzHeldAttestations.authTagFor("d".repeat(64)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuzzTypingStateTest {
|
||||
private val channel = "chan-1"
|
||||
private val alice = "a".repeat(64)
|
||||
private val bob = "b".repeat(64)
|
||||
|
||||
@BeforeTest fun setup() = BuzzTypingState.clearForTesting()
|
||||
|
||||
@AfterTest fun teardown() = BuzzTypingState.clearForTesting()
|
||||
|
||||
@Test
|
||||
fun recordsAFreshHeartbeat() {
|
||||
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100)
|
||||
assertEquals(setOf(alice), BuzzTypingState.flow.value[channel]?.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleOnArrivalIsDropped() {
|
||||
// atSecs is older than nowSecs by more than the window → never recorded.
|
||||
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100 + BuzzTypingState.TYPING_STALE_SECS + 1)
|
||||
assertNull(BuzzTypingState.flow.value[channel])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewerHeartbeatWins_andStaleTypistsArePruned() {
|
||||
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100)
|
||||
BuzzTypingState.record(channel, bob, atSecs = 101, nowSecs = 101)
|
||||
assertEquals(setOf(alice, bob), BuzzTypingState.flow.value[channel]?.keys)
|
||||
|
||||
// A heartbeat far in the future ages both prior ones out of the window; only the
|
||||
// fresh one survives, and the newer stamp for bob is what's kept.
|
||||
val later = 101 + BuzzTypingState.TYPING_STALE_SECS + 5
|
||||
BuzzTypingState.record(channel, bob, atSecs = later, nowSecs = later)
|
||||
assertEquals(setOf(bob), BuzzTypingState.flow.value[channel]?.keys)
|
||||
assertEquals(later, BuzzTypingState.flow.value[channel]?.get(bob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun futureDatedHeartbeatIsClampedToNow() {
|
||||
// A peer claiming a wildly-future createdAt must not become un-expirable.
|
||||
BuzzTypingState.record(channel, alice, atSecs = 10_000, nowSecs = 100)
|
||||
assertEquals(100L, BuzzTypingState.flow.value[channel]?.get(alice))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelsAreIsolated() {
|
||||
BuzzTypingState.record("c1", alice, atSecs = 100, nowSecs = 100)
|
||||
BuzzTypingState.record("c2", bob, atSecs = 100, nowSecs = 100)
|
||||
assertEquals(setOf(alice), BuzzTypingState.flow.value["c1"]?.keys)
|
||||
assertEquals(setOf(bob), BuzzTypingState.flow.value["c2"]?.keys)
|
||||
assertTrue(BuzzTypingState.flow.value.size == 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
# Buzz protocol (`com.vitorpamplona.quartz.buzz`)
|
||||
|
||||
Quartz-native models for the [`block/buzz`](https://github.com/block/buzz) protocol —
|
||||
a self-hosted "workspace where humans and agents build together, on a relay you own."
|
||||
|
||||
Buzz runs **NIP-01 on the wire** and uses the `kind` integer as its only dispatch
|
||||
switch. Architecturally it is a member of the **NIP-29 relay-group family**, *not* an
|
||||
encrypted client-side community like `concord`: the relay is the single source of
|
||||
truth, it enforces membership server-side, it keeps content **plaintext** (so it can
|
||||
run full-text search, audit, and workflows), and it signs overlay/state events. Buzz
|
||||
literally reuses NIP-29 primitives — the `h` channel tag, kind `9` stream messages,
|
||||
the `9000`/`9001`/`9002` admin events, and the `39000`/`39002` relay-signed metadata —
|
||||
and layers an agent + workspace vocabulary on top.
|
||||
|
||||
Because of that lineage, this package models only the **Buzz-custom extensions**. The
|
||||
standard/NIP-29 kinds are reused from their existing Quartz packages
|
||||
(`nip29RelayGroups`, `nip34Git`, `nip51Lists`, `nip42RelayAuth`, `nip43RelayMembers`,
|
||||
…). There is no central kind registry — each event declares its own `const val KIND`
|
||||
on its companion (the idiomatic Quartz convention), and dispatch is wired in
|
||||
`utils/EventFactory.kt` like every other kind.
|
||||
|
||||
## Source of truth
|
||||
|
||||
The prose specs under Buzz's `docs/nips/` are **drafts and lag the code**. Every model
|
||||
here is confirmed against the authoritative Rust — `crates/buzz-core` (the per-kind
|
||||
modules), `crates/buzz-sdk` (event builders, `nip_oa.rs`) — not the markdown.
|
||||
|
||||
Compliance is verified against **vectors generated by Buzz's own code**, not
|
||||
hand-transcribed schemas:
|
||||
|
||||
- **NIP-OA** pins Buzz's published known-answer vector (`OwnerAttestationTest`): our
|
||||
preimage hash equals Buzz's SHA-256 and our verifier accepts Buzz's reference
|
||||
signature.
|
||||
- **NIP-AM** pins a real signed event emitted by a tiny generator built on the real
|
||||
`buzz-core` (deterministic keys owner sk=1 / agent sk=2). `AgentTurnMetricVectorTest`
|
||||
(jvmTest) dispatches it through `EventFactory`, NIP-44-decrypts it, and asserts the
|
||||
payload — proving Quartz reads real Buzz events end to end. The fixture and its
|
||||
generator live at `quartz/src/jvmTest/resources/buzz/turn_metric_vector.json` and
|
||||
`.../gen_turn_metric.rs` (drop the `.rs` into a `block/buzz` checkout's
|
||||
`crates/buzz-core/examples/` and run `cargo run -p buzz-core --example gen_turn_metric`
|
||||
to regenerate).
|
||||
|
||||
## Layout
|
||||
|
||||
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
|
||||
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
|
||||
conflicts below).
|
||||
|
||||
| Package | Buzz NIP | Kind(s) |
|
||||
|---|---|---|
|
||||
| `oaOwnerAttestation` | NIP-OA | `auth` tag (no kind) |
|
||||
| `amTurnMetrics` | NIP-AM | 44200 |
|
||||
| `aoObserver` | NIP-AO | 24200 |
|
||||
| `aeEngrams` | NIP-AE | 30174 |
|
||||
| `apPersonas` / `teams` / `managedAgents` / `agentProfiles` | NIP-AP + agent identity | 30175 / 30176 / 30177 / 10100 |
|
||||
| `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 |
|
||||
| `relayAdmin` / `moderation` | admin + moderation | 9030-9032 / 9040-9044, 42000 |
|
||||
| `stream` / `stream.sidecars` | stream messaging | 40002-40008, 40099, 40100 / 40901, 40902 |
|
||||
| `dm` / `jobs` | DMs / agent jobs | 41001, 41010-41012 / 43001-43006 |
|
||||
| `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 |
|
||||
| `rsReadState` | NIP-RS | (helpers on `AppSpecificDataEvent`, kind 30078) |
|
||||
|
||||
### Kind conflicts — implemented but NOT registered in EventFactory
|
||||
|
||||
These Buzz kind numbers are already owned by an existing Amethyst/Nostr class, so the
|
||||
Buzz model exists (build/parse it explicitly) but the incumbent keeps the `EventFactory`
|
||||
dispatch slot:
|
||||
|
||||
| Kind | Buzz class | Incumbent (registered) |
|
||||
|---|---|---|
|
||||
| 9041 | `moderation.ModerationUnbanEvent` | `nip75ZapGoals.GoalEvent` |
|
||||
| 20001 | `presence.PresenceUpdateEvent` | `experimental.bitchat.geohash.GeohashPresenceEvent` |
|
||||
| 39005 | `cwChannelWindow.ThreadSummaryEvent` | `nip29RelayGroups.metadata.GroupPinnedEvent` |
|
||||
| 49001 | `media.MediaUploadEvent` | — (Buzz's own `kind.rs` marks 49001 "Not a relay event kind") |
|
||||
| 30078 | `rsReadState` (helpers) | `nip78AppData.AppSpecificDataEvent` (NIP-RS reuses 30078) |
|
||||
|
||||
### Confidence: source-confirmed vs. inferred
|
||||
|
||||
Most kinds' tags/content are confirmed against a concrete Rust builder or validator. A
|
||||
minority are only *reserved* in `kind.rs` with no constructor anywhere in the Buzz repo;
|
||||
those are modeled conservatively (tolerant, `ignoreUnknownKeys`) and carry a schema
|
||||
caveat in their KDoc. Inferred kinds include: jobs **43001-43006** (reserved, no builder,
|
||||
no relay scope — the most speculative), the workflow lifecycle events **46001-46007 /
|
||||
46011 / 46012**, stream **40004-40007** and the **40901/40902** sidecars, **48001** audit,
|
||||
and **48106** huddle guidelines. Treat these as scaffolding to reconcile once Buzz
|
||||
defines them.
|
||||
|
||||
## Live interoperability — verified against a running Buzz relay
|
||||
|
||||
`buzz/interop/BuzzRelayLiveInteropTest.kt` (jvmTest, env-gated so CI skips it) drives a
|
||||
**real `buzz-relay`** (Postgres + Redis + MinIO via Buzz's compose, relay via
|
||||
`cargo run -p buzz-relay`, members enrolled with `buzz-admin add-member`):
|
||||
|
||||
```
|
||||
BUZZ_RELAY_WS=ws://localhost:3000 \
|
||||
BUZZ_MEMBER_SK=<member sk hex> BUZZ_OWNER_SK=<member sk hex> \
|
||||
./gradlew :quartz:jvmTest --tests "*BuzzRelayLiveInteropTest"
|
||||
```
|
||||
|
||||
Verified end-to-end (relay log shows the ingests):
|
||||
- **NIP-42 auth** with a member key, and — critically — **NIP-OA/NIP-AA agent auth**: a
|
||||
brand-new, un-enrolled agent key authenticated using only our owner-signed `auth`
|
||||
tag on the AUTH event (the relay's NIP-OA membership fallback accepted it).
|
||||
- **Channel lifecycle**: kind:9007 create (via the existing NIP-29 `CreateGroupEvent` —
|
||||
Buzz channels ARE NIP-29 groups) then kind:40002 publish, `REQ` round-trip with a
|
||||
byte-identical echo (same event id), and `EOSE`.
|
||||
|
||||
Interop lessons encoded in the test:
|
||||
- Buzz tenancy is **host-bound**: connect with the exact host the community was bound
|
||||
to (`ws://localhost:3000`, not `ws://127.0.0.1:3000`) or the upgrade 404s.
|
||||
- `h` tag values must parse as **UUIDs** (`extract_channel_id`); a non-UUID `h` is
|
||||
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/BuzzWorkspaceStates.kt` — Buzz-only overlay state (the
|
||||
kind-40003 **edit overlay** — never render superseded text as current — and the
|
||||
newest kind-40100 **canvas**), held in a registry keyed by the channel's UUID,
|
||||
**outside** the channel object. This is deliberate: screens/feeds/composers capture
|
||||
their `RelayGroupChannel` instance for the whole session, so the dialect must be able
|
||||
to flip mid-session without swapping the object (an earlier subclass-swap design froze
|
||||
every live reference). Kind-9 chat and kind-40002 stream messages share ONE
|
||||
`RelayGroupChannel` 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`) request the whole Buzz timeline
|
||||
set — 40002/40003/40008/40099, canvas 40100, forum 45001-45003, agent jobs 43001-43006,
|
||||
huddle lifecycle 48100-48103 — **unconditionally** alongside the NIP-29 kinds (they match
|
||||
nothing on a vanilla relay; all are `h`-scoped so the same `#h` group REQ returns them).
|
||||
A dialect-gated version was reverted: gating advanced history cursors past ranges that
|
||||
had been queried without the Buzz kinds, permanently skipping older workspace messages.
|
||||
- Kind-aware chat rendering (`RenderBuzzNotes` + `ChatMessageCompose`): 40002 as text,
|
||||
40003 as an edit overlay, 40099 as a system row, **40008 diffs** as a monospace scrollable
|
||||
block with a repo/commit/file header, **agent-job (43xxx) + huddle (48xxx) lifecycle** as
|
||||
centered system narration (huddles carry JSON content, so they must not fall through to a
|
||||
raw text bubble), and **forum votes (45002)** as a glyph line. Forum posts stay plain text.
|
||||
- Dialect marking fires only off a **verified** event (`markBuzzIfVerified`), so an
|
||||
unverifiable frame from a hostile/buggy relay can't flip what the composer sends.
|
||||
|
||||
### NIP-OA agent auth at connect — implemented
|
||||
|
||||
`commons/.../model/buzz/BuzzHeldAttestations.kt` holds the attestations this device has
|
||||
*received* (keyed by the agent pubkey they authorize; only `verify`-passing ones are
|
||||
stored). When an account whose pubkey matches a held attestation AUTHs (NIP-42) to a
|
||||
**Buzz-dialect** relay, `AuthCoordinator.buzzAugmented` appends the owner-signed `auth`
|
||||
tag to that account's AUTH event — and only that account's, never the Concord stream-key
|
||||
AUTHs that share the template — so an un-enrolled agent key gets virtual membership while
|
||||
its owner stays a member. The paste/hold + owner-side issue flows both live in
|
||||
`AgentAttestationScreen`. (Store is in-memory for now; persisting per-account is a follow-up.)
|
||||
|
||||
The **canvas (40100)** now has a viewer: `BuzzWorkspaceState` exposes a `canvasUpdates`
|
||||
flow and `amethyst/.../buzz/BuzzCanvasScreen` (`Route.BuzzCanvas`) renders the newest
|
||||
markdown, reached from a Dashboard action in the relay-group top bar that appears only on
|
||||
a Buzz relay once a canvas has arrived.
|
||||
|
||||
The **edit composer (40003)** is wired: `ChannelNewMessageViewModel` has a Buzz edit mode
|
||||
(`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
|
||||
own 40002 messages threads to the composer through the shared chat feed via an optional
|
||||
`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
|
||||
already rendered).
|
||||
|
||||
The **social/moderation kinds work through the shared chat action sheet with no Buzz-specific
|
||||
wiring**, because Buzz's relay accept-list (`buzz-core/src/kind.rs` `ALL_KINDS`) deliberately
|
||||
includes the standard Nostr kinds: **reactions** (NIP-25 kind 7 — `requires_h_channel_scope`
|
||||
is false, so no h-tag needed), **deletions** (NIP-09 kind 5, plus NIP-29 group delete 9005/9008),
|
||||
**reports** (NIP-56 kind 1984, queued to the tenant's `moderation_reports`), and **custom emoji**
|
||||
(10030/30030). Amethyst's `ChatMessageActionSheet` already produces these and they are accepted,
|
||||
stored, and served by the Buzz relay. The one social action NOT in Buzz's accept-list is **zaps**
|
||||
(no 9734/9735) — but a zap is a Lightning payment whose receipt is published by the LN provider to
|
||||
the author's relays, so zapping still works; only the receipt isn't stored on the Buzz relay.
|
||||
|
||||
**Live typing indicators (kind 20002)** are wired: `commons/.../model/buzz/BuzzTypingState`
|
||||
is a process-wide, lock-guarded `channel -> typist -> heartbeat` registry (6 unit tests);
|
||||
`LocalCache` records each ephemeral 20002 into it (still no feed row), the open channel's
|
||||
live tail requests 20002 (`RELAY_GROUP_OPEN_TAIL_KINDS`, scoped to the room on screen),
|
||||
`Account.sendBuzzTyping` fires a throttled heartbeat from the composer, and
|
||||
`BuzzTypingIndicator` renders an animated "… is typing" row above the input. This matches
|
||||
Concord's typing feature. (`requires_h_channel_scope(20002)` is false on the relay, but
|
||||
Amethyst still `h`-scopes the send + subscription so it never leaks across channels.)
|
||||
|
||||
**Navigation identity — a "Workspaces" tab.** `NavBarItem.BUZZ` (→ `Route.BuzzWorkspaces`)
|
||||
is a pinnable bottom-nav destination; `amethyst/.../buzz/BuzzWorkspacesScreen` is a hub that
|
||||
filters the joined relay groups to Buzz-dialect relays and leads with an Agent-Console hero
|
||||
card. Buzz workspaces still ALSO appear in the generic Relay Groups list (they are NIP-29
|
||||
groups); the tab is a Buzz-branded lens over the same data, not a separate store.
|
||||
|
||||
Still not wired: the forum/job/huddle/DM message composers — each a send flow for a
|
||||
richer kind, and jobs/huddles are among the schema-inferred kinds, so they want Buzz-side
|
||||
confirmation before UI. **Presence (kind 20001)** is accepted by Buzz but collides with
|
||||
Amethyst's `GeohashPresenceEvent` in `EventFactory` (20001 is registered as geohash
|
||||
presence), so it needs disambiguation before it can render — unlike typing (20002), which
|
||||
has no such collision. A **workspace→channels shell** (Concord-style channel sidebar) and
|
||||
per-account persistence of held attestations remain follow-ups.
|
||||
|
||||
## Owner Attestation (NIP-OA) — implemented
|
||||
|
||||
The primitive that makes "agents as first-class members" work without enrolling every
|
||||
agent key. An owner signs a standalone commitment authorizing an agent pubkey to
|
||||
publish under conditions; the relay grants virtual membership while the owner is a
|
||||
member. See `oaOwnerAttestation/OwnerAttestation.kt`.
|
||||
|
||||
```
|
||||
commitment = "nostr:agent-auth:" + agentPubKey + ":" + conditions
|
||||
message = SHA-256(commitment)
|
||||
sig = BIP-340 Schnorr(message, ownerPrivKey) // over the 32-byte digest
|
||||
tag = ["auth", ownerPubKey, conditions, sig]
|
||||
```
|
||||
|
||||
The signed message is not an event, so only a signer holding the raw owner private key
|
||||
can produce it (a NIP-46 bunker / NIP-55 external signer cannot) — hence `sign()` takes
|
||||
a `KeyPair`/private key, not a `NostrSigner`.
|
||||
|
||||
## Agent Turn Metric (NIP-AM, kind:44200) — implemented
|
||||
|
||||
An encrypted, durable record of one agent turn's token usage and cost, published by the
|
||||
agent to its owner. `content` is a NIP-44 v2 ciphertext of `AgentTurnMetricPayload`
|
||||
(camelCase) between the agent (author + `agent` tag) and the owner (`p` tag); either
|
||||
party can decrypt. See `amTurnMetrics/AgentTurnMetricEvent.kt`.
|
||||
|
||||
## Agent-owner console (commons + amethyst) — implemented
|
||||
|
||||
The owner-facing dashboard over their AI-agent fleet. Reachable from Settings → App →
|
||||
"Agent console" (`Route.AgentConsole`), three tabs:
|
||||
|
||||
- **Costs** — decrypted NIP-AM turn metrics (`kind:44200`, `p` = owner) rolled up by the
|
||||
pure `commons/.../model/buzz/AgentFleetMetrics.kt::AgentFleetAggregator`. Cost/token
|
||||
semantics are the aggregator's, not the UI's: per `(agent, session)` the **max
|
||||
cumulative** per field is the authoritative session total (robust to dropped turns),
|
||||
falling back per-field to **summing the `turn` deltas** only when no cumulative exists;
|
||||
a delta-sum touching a `deltaReliable == false` turn flags the fleet total estimated.
|
||||
Ten aggregator tests pin these (cumulative-not-summed, missing-turn recovery, per-field
|
||||
mixing, sessionless singletons, unreliable flag).
|
||||
- **Personas** — the owner's plaintext persona definitions (NIP-AP `kind:30175`).
|
||||
- **Observer** — a **live** subscription to ephemeral telemetry frames (NIP-AO
|
||||
`kind:24200`, `p` = owner) via `subscribeAsFlow`, decrypted `frame:telemetry` only,
|
||||
newest-first, bounded to a 200-row ring. Opened only while the tab is on screen
|
||||
(`DisposableEffect`) because relays and `LocalCache` never store observer frames.
|
||||
|
||||
`AgentConsoleViewModel` fetches 44200 + 30175 from the Buzz-dialect relays plus the
|
||||
owner's outbox set, lets `LocalCache` consume them, decrypts with the owner signer
|
||||
(cached by event id), and re-derives the aggregate.
|
||||
|
||||
A **"Attest" FAB** opens `AgentAttestationScreen` — owner-side **NIP-OA issuance**. The
|
||||
owner enters an agent pubkey (npub/hex) and optional `AttestationConditions` (kind, and/or
|
||||
`created_at` before/after bounds); `OwnerAttestation.sign(agent, conditions, ownerKey)`
|
||||
produces the signed `auth` tag, shown for copy so the owner hands it to the agent operator
|
||||
out-of-band. It is entirely offline (nothing is published) and needs the owner's **raw
|
||||
private key** — accounts on a NIP-46 bunker / NIP-55 external signer get an explanation
|
||||
instead of the form, because the signature covers a hashed commitment rather than an event.
|
||||
|
||||
Read-only v1 for the tabs — persona editing is the remaining follow-up.
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.aeEngrams
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* The decrypted body of an Agent Engram (NIP-AE, `kind:30174`). The `slug`
|
||||
* discriminates the variant: `core` is the agent's identity surface, everything
|
||||
* else (`mem/…`) is one memory entry. Ground truth:
|
||||
* `buzz-core/src/engram.rs` (`Body`).
|
||||
*
|
||||
* Bodies are serialized whitespace-free with `slug` first, and — unlike the rest
|
||||
* of the Buzz payloads — `null` is written explicitly (a memory with `value:null`
|
||||
* is a **tombstone**, distinct from an absent field), so [JSON] keeps
|
||||
* `explicitNulls = true` to match Buzz's byte-exact encoding.
|
||||
*/
|
||||
sealed interface EngramBody {
|
||||
/** The slug this body addresses. */
|
||||
val slug: String
|
||||
|
||||
fun encodeToJson(): String
|
||||
|
||||
companion object {
|
||||
const val CORE_SLUG = "core"
|
||||
|
||||
val JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a decrypted engram body, dispatching on `slug`: a `core` slug
|
||||
* yields an [EngramCoreBody], anything else an [EngramMemoryBody]. Throws
|
||||
* if `slug` is missing or not a string.
|
||||
*/
|
||||
fun decodeFromJson(json: String): EngramBody {
|
||||
val slug =
|
||||
JSON
|
||||
.parseToJsonElement(json)
|
||||
.jsonObject["slug"]
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?: throw IllegalArgumentException("Engram body is missing a string `slug`")
|
||||
|
||||
return if (slug == CORE_SLUG) {
|
||||
JSON.decodeFromString<EngramCoreBody>(json)
|
||||
} else {
|
||||
require(isValidMemorySlug(slug)) { "Invalid engram memory slug: $slug" }
|
||||
JSON.decodeFromString<EngramMemoryBody>(json)
|
||||
}
|
||||
}
|
||||
|
||||
private val MEMORY_SLUG = Regex("^mem/[a-z0-9][a-z0-9_-]{0,63}(/[a-z0-9][a-z0-9_-]{0,63})*$")
|
||||
|
||||
/** NIP-AE memory-slug grammar (`validate_slug` in engram.rs); `core` is handled separately. */
|
||||
fun isValidMemorySlug(slug: String): Boolean = slug.length <= 255 && MEMORY_SLUG.matches(slug)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `mem/…` engram entry. [value] `null` is a tombstone (the entry was deleted),
|
||||
* encoded as an explicit `"value":null` on the wire.
|
||||
*
|
||||
* [value] has NO default on purpose: a memory body that OMITS `value` entirely is
|
||||
* invalid (Buzz's `Body::from_json_bytes` rejects it), not a tombstone. With no
|
||||
* default, kotlinx throws on an absent field, so Amethyst refuses the same bodies
|
||||
* Buzz does instead of silently reading them as a deletion.
|
||||
*/
|
||||
@Serializable
|
||||
data class EngramMemoryBody(
|
||||
override val slug: String,
|
||||
val value: String?,
|
||||
) : EngramBody {
|
||||
/** `true` for a tombstone — a memory whose [value] is `null`. */
|
||||
fun isTombstone(): Boolean = value == null
|
||||
|
||||
override fun encodeToJson(): String = EngramBody.JSON.encodeToString(this)
|
||||
}
|
||||
|
||||
/** The reserved `core` engram — the agent's free-form identity/profile surface. */
|
||||
@Serializable
|
||||
data class EngramCoreBody(
|
||||
override val slug: String = EngramBody.CORE_SLUG,
|
||||
val profile: String,
|
||||
) : EngramBody {
|
||||
override fun encodeToJson(): String = EngramBody.JSON.encodeToString(this)
|
||||
}
|
||||
@@ -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.aeEngrams
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.mac.MacInstance
|
||||
|
||||
/**
|
||||
* Derivation of the blinded `d` tag for an Agent Engram (NIP-AE, `kind:30174`).
|
||||
*
|
||||
* The address is `(pubkey_a, kind, d)` where
|
||||
* `d = lower_hex(HMAC-SHA256(K_c, "agent-memory/v1/d-tag" || 0x00 || slug))`,
|
||||
* 64 hex characters. `K_c` is the NIP-44 v2 conversation key shared by the agent
|
||||
* and its owner — blinding the slug so a relay can address the record without
|
||||
* learning it. Ground truth: `buzz-core/src/engram.rs` (`d_tag`, `D_TAG_DOMAIN`).
|
||||
*
|
||||
* ## Limitation
|
||||
* Quartz's [com.vitorpamplona.quartz.nip01Core.signers.NostrSigner] does **not**
|
||||
* expose the NIP-44 conversation key (it is held inside the signer, and remote
|
||||
* NIP-46 / external NIP-55 signers never reveal it), so the d tag cannot be
|
||||
* derived from a signer alone. Callers that hold the raw conversation key bytes
|
||||
* (e.g. a local keypair path that computes `K_c` out of band) can use [derive];
|
||||
* otherwise the d tag must be supplied to
|
||||
* [EngramEvent.create] as an opaque 64-hex string obtained from wherever `K_c`
|
||||
* is available.
|
||||
*/
|
||||
object EngramDTag {
|
||||
/**
|
||||
* Domain-separation prefix for the `d`-tag HMAC, followed by a `0x00` byte and
|
||||
* the slug. Versioned independently of the NIP number.
|
||||
*/
|
||||
const val DOMAIN = "agent-memory/v1/d-tag"
|
||||
|
||||
/**
|
||||
* Computes the blinded `d` tag for [slug] under the raw NIP-44 v2 conversation
|
||||
* key [conversationKey] (32 bytes). Returns 64 lowercase hex characters.
|
||||
*/
|
||||
fun derive(
|
||||
conversationKey: ByteArray,
|
||||
slug: String,
|
||||
): HexKey {
|
||||
val mac = MacInstance("HmacSHA256", conversationKey)
|
||||
mac.update(DOMAIN.encodeToByteArray())
|
||||
mac.update(0x00)
|
||||
mac.update(slug.encodeToByteArray())
|
||||
return mac.doFinal().toHexKey()
|
||||
}
|
||||
}
|
||||
@@ -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.aeEngrams
|
||||
|
||||
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.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* A Buzz Agent Engram (NIP-AE, `kind:30174`): an addressable, encrypted memory
|
||||
* record for an AI agent, authored by the agent and readable by its owner.
|
||||
*
|
||||
* Addressed by `(pubkey_a, kind, d)`, where the `d` tag is a **blinded** slug —
|
||||
* `HMAC-SHA256(K_c, "agent-memory/v1/d-tag" || 0x00 || slug)` over the agent
|
||||
* owner NIP-44 conversation key (see [EngramDTag]). The `content` is a NIP-44 v2
|
||||
* ciphertext of an [EngramBody] (a `mem/…` memory or the `core` identity).
|
||||
* Because the conversation key is symmetric, either the agent (author) or the
|
||||
* owner (`p` tag) can [decrypt]. Ground truth: `buzz-core/src/engram.rs`.
|
||||
*
|
||||
* ## d-tag limitation
|
||||
* Quartz's [NostrSigner] does not expose the NIP-44 conversation key, so the `d`
|
||||
* tag cannot be derived from the signer during [create]. The caller MUST supply
|
||||
* it as an opaque 64-hex string — compute it with [EngramDTag.derive] wherever
|
||||
* the raw conversation-key bytes are available.
|
||||
*/
|
||||
@Immutable
|
||||
class EngramEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
/** The owner (recipient) pubkey — the single `p` tag. */
|
||||
fun ownerPubKey() = tags.engramOwner()
|
||||
|
||||
/**
|
||||
* Decrypts and parses the engram body. [signer] must belong to the agent
|
||||
* (this event's author) or the owner (`p` tag). Throws on a missing
|
||||
* counterparty, decryption failure, or malformed body; use [decryptOrNull]
|
||||
* to swallow those.
|
||||
*/
|
||||
suspend fun decrypt(signer: NostrSigner): EngramBody {
|
||||
val counterparty = if (signer.pubKey == pubKey) ownerPubKey() else pubKey
|
||||
requireNotNull(counterparty) { "Engram is missing the owner (p) tag" }
|
||||
val json = signer.decrypt(content, counterparty)
|
||||
return EngramBody.decodeFromJson(json)
|
||||
}
|
||||
|
||||
suspend fun decryptOrNull(signer: NostrSigner): EngramBody? =
|
||||
try {
|
||||
decrypt(signer)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 30174
|
||||
|
||||
/**
|
||||
* Builds and signs an engram: [signer] is the agent; the [body] is
|
||||
* NIP-44-encrypted to [ownerPubKey]. [dTag] is the blinded slug address —
|
||||
* an opaque 64-hex string the caller derives via [EngramDTag.derive] (the
|
||||
* signer cannot supply the conversation key needed to compute it).
|
||||
*/
|
||||
suspend fun create(
|
||||
body: EngramBody,
|
||||
ownerPubKey: HexKey,
|
||||
dTag: HexKey,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): EngramEvent {
|
||||
val ciphertext = signer.nip44Encrypt(body.encodeToJson(), ownerPubKey)
|
||||
return signer.sign(build(ciphertext, ownerPubKey, dTag, createdAt))
|
||||
}
|
||||
|
||||
fun build(
|
||||
ciphertext: String,
|
||||
ownerPubKey: HexKey,
|
||||
dTag: HexKey,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<EngramEvent>.() -> Unit = {},
|
||||
) = eventTemplate<EngramEvent>(KIND, ciphertext, createdAt) {
|
||||
dTag(dTag)
|
||||
owner(ownerPubKey)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.aeEngrams
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
|
||||
fun TagArrayBuilder<EngramEvent>.owner(ownerPubKey: HexKey) = addUnique(PTag.assemble(ownerPubKey, null))
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.aeEngrams
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
|
||||
/** The owner (recipient) pubkey — the single `p` tag. */
|
||||
fun TagArray.engramOwner() = firstNotNullOfOrNull(PTag::parseKey)
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.agentProfiles
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* The JSON body stored in a Buzz Agent Profile event ([AgentProfileEvent], `kind:10100`).
|
||||
*
|
||||
* MODELLED CONSERVATIVELY — FLAGGED. Buzz has no single authoritative struct for `kind:10100`
|
||||
* content; the fields below are the union of the two consumers found in the Rust/TS ground truth:
|
||||
*
|
||||
* 1. The relay side effect `handle_agent_profile` in
|
||||
* `buzz-relay/src/handlers/side_effects.rs` reads exactly one field, [channelAddPolicy]
|
||||
* (`channel_add_policy`), to set the author's channel-add policy. The CLI writer
|
||||
* (`buzz-cli/.../channels.rs`) publishes `{"channel_add_policy": "<policy>"}` and nothing
|
||||
* else. Observed policy values: `anyone`, `owner_only`, `nobody`.
|
||||
* 2. The desktop agent-discovery reader `agents_from_events` in
|
||||
* `desktop/src-tauri/src/nostr_convert.rs` treats the content as loose kind-0-like agent
|
||||
* metadata, reading [name], [displayName], [agentType], [status], [capabilities],
|
||||
* [channelIds] and defaulting each when absent.
|
||||
*
|
||||
* No writer emits all of these together, so every field is optional and unknown keys are
|
||||
* tolerated. The event author is the authoritative pubkey; any `pubkey` claimed inside the
|
||||
* content is deliberately NOT modelled here (upstream overwrites it with the signer). If a
|
||||
* definitive schema is published later, tighten this struct.
|
||||
*/
|
||||
@Serializable
|
||||
data class AgentProfileContent(
|
||||
@SerialName("channel_add_policy") val channelAddPolicy: String? = null,
|
||||
val name: String? = null,
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
@SerialName("agent_type") val agentType: String? = null,
|
||||
val status: String? = null,
|
||||
val capabilities: List<String> = emptyList(),
|
||||
@SerialName("channel_ids") val channelIds: List<String> = emptyList(),
|
||||
) {
|
||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||
|
||||
companion object {
|
||||
val JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun decodeFromJson(json: String): AgentProfileContent = JSON.decodeFromString(json)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.agentProfiles
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
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
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* A Buzz Agent Profile (`kind:10100`): a replaceable, world-readable, agent-authored event
|
||||
* carrying agent metadata + the author's channel-add policy. Replaceable (10000-19999), so it
|
||||
* is keyed by `(pubkey, 10100)` with an empty `d` tag and there is at most one per author.
|
||||
*
|
||||
* The `content` is a loose JSON [AgentProfileContent] — see that class for the FLAG: Buzz has no
|
||||
* single authoritative schema, so the model is conservative and tolerant of unknown keys.
|
||||
* Ground truth consumers: `buzz-relay/src/handlers/side_effects.rs::handle_agent_profile`
|
||||
* (reads `channel_add_policy`) and `desktop/src-tauri/src/nostr_convert.rs::agents_from_events`
|
||||
* (reads loose agent-discovery metadata).
|
||||
*/
|
||||
@Immutable
|
||||
class AgentProfileEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** Parses the profile metadata, or throws if the JSON is malformed. */
|
||||
fun profile(): AgentProfileContent = AgentProfileContent.decodeFromJson(content)
|
||||
|
||||
fun profileOrNull(): AgentProfileContent? =
|
||||
try {
|
||||
profile()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 10100
|
||||
|
||||
fun build(
|
||||
profile: AgentProfileContent,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<AgentProfileEvent>.() -> Unit = {},
|
||||
) = eventTemplate<AgentProfileEvent>(KIND, profile.encodeToJson(), createdAt) {
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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.amTurnMetrics
|
||||
|
||||
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.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* A Buzz Agent Turn Metric (NIP-AM, `kind:44200`): a durable, encrypted record of one
|
||||
* AI-agent turn's token usage and cost, published by the agent to its owner.
|
||||
*
|
||||
* The `content` is a NIP-44 v2 ciphertext of an [AgentTurnMetricPayload], encrypted
|
||||
* between the agent (the event author, also named by the `agent` tag) and the owner
|
||||
* (the single `p` tag). Because the NIP-44 conversation key is symmetric, either party
|
||||
* can decrypt with [decrypt]. Ground truth: `buzz-core/src/agent_turn_metric.rs`.
|
||||
*/
|
||||
@Immutable
|
||||
class AgentTurnMetricEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
/** The owner (recipient) pubkey — the `p` tag. */
|
||||
fun ownerPubKey() = tags.turnMetricOwner()
|
||||
|
||||
/** The publishing agent's pubkey — the `agent` tag (normally equal to [pubKey]). */
|
||||
fun agentPubKey() = tags.turnMetricAgent()
|
||||
|
||||
/**
|
||||
* Decrypts and parses the metric payload. [signer] must belong to either the agent
|
||||
* (this event's author) or the owner. Throws on a missing counterparty, decryption
|
||||
* failure, or malformed payload; use [decryptOrNull] to swallow those.
|
||||
*/
|
||||
suspend fun decrypt(signer: NostrSigner): AgentTurnMetricPayload {
|
||||
val counterparty = if (signer.pubKey == pubKey) ownerPubKey() else pubKey
|
||||
requireNotNull(counterparty) { "Turn metric is missing the owner (p) tag" }
|
||||
val json = signer.decrypt(content, counterparty)
|
||||
return AgentTurnMetricPayload.decodeFromJson(json)
|
||||
}
|
||||
|
||||
suspend fun decryptOrNull(signer: NostrSigner): AgentTurnMetricPayload? =
|
||||
try {
|
||||
decrypt(signer)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 44200
|
||||
|
||||
/**
|
||||
* Builds and signs a turn-metric event: [signer] is the agent; the payload is
|
||||
* NIP-44-encrypted to [ownerPubKey]. Fails if the payload violates NIP-AM
|
||||
* numeric validity (a negative or non-finite `costUsd`).
|
||||
*/
|
||||
suspend fun create(
|
||||
payload: AgentTurnMetricPayload,
|
||||
ownerPubKey: HexKey,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): AgentTurnMetricEvent {
|
||||
require(payload.isValid()) { "costUsd must be finite and non-negative" }
|
||||
val ciphertext = signer.nip44Encrypt(payload.encodeToJson(), ownerPubKey)
|
||||
return signer.sign(build(ciphertext, ownerPubKey, signer.pubKey, createdAt))
|
||||
}
|
||||
|
||||
fun build(
|
||||
ciphertext: String,
|
||||
ownerPubKey: HexKey,
|
||||
agentPubKey: HexKey,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<AgentTurnMetricEvent>.() -> Unit = {},
|
||||
) = eventTemplate<AgentTurnMetricEvent>(KIND, ciphertext, createdAt) {
|
||||
owner(ownerPubKey)
|
||||
agent(agentPubKey)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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.amTurnMetrics
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* The decrypted payload of a Buzz Agent Turn Metric (NIP-AM, `kind:44200`) — per-turn
|
||||
* token usage and cost for one AI-agent turn.
|
||||
*
|
||||
* Field names, types, and nullability mirror `AgentTurnMetricPayload` in Buzz's
|
||||
* `buzz-core/src/agent_turn_metric.rs` (camelCase on the wire). [harness] and
|
||||
* [timestamp] are required; every other field is optional. Token fields are nullable
|
||||
* 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
|
||||
* kept verbatim (map it with [stopReasonOrUnknown]) rather than dropping the payload.
|
||||
*/
|
||||
@Serializable
|
||||
data class AgentTurnMetricPayload(
|
||||
val harness: String,
|
||||
val timestamp: String,
|
||||
val model: String? = null,
|
||||
val channelId: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val turnId: String? = null,
|
||||
val turnSeq: Long? = null,
|
||||
val turn: TokenCounts? = null,
|
||||
val cumulative: TokenCounts? = null,
|
||||
val deltaReliable: Boolean = true,
|
||||
val stopReason: String? = null,
|
||||
) {
|
||||
/** Maps [stopReason] to a [StopReason], treating any unrecognized value as [StopReason.UNKNOWN]. */
|
||||
fun stopReasonOrUnknown(): StopReason? = stopReason?.let { StopReason.fromWire(it) }
|
||||
|
||||
/**
|
||||
* NIP-AM numeric validity: any present `costUsd` must be finite and non-negative.
|
||||
* (Token counts are non-negative by type.)
|
||||
*/
|
||||
fun isValid(): Boolean = turn.isCostValid() && cumulative.isCostValid()
|
||||
|
||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||
|
||||
companion object {
|
||||
val JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun decodeFromJson(json: String): AgentTurnMetricPayload = JSON.decodeFromString(json)
|
||||
|
||||
private fun TokenCounts?.isCostValid(): Boolean {
|
||||
val cost = this?.costUsd ?: return true
|
||||
return cost.isFinite() && cost >= 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token usage counts. All fields nullable — `null` distinguishes "not reported" from
|
||||
* zero. Mirrors `TokenCounts` in `agent_turn_metric.rs`.
|
||||
*/
|
||||
@Serializable
|
||||
data class TokenCounts(
|
||||
val inputTokens: Long? = null,
|
||||
val outputTokens: Long? = null,
|
||||
val totalTokens: Long? = null,
|
||||
val costUsd: Double? = null,
|
||||
val cacheReadTokens: Long? = null,
|
||||
val cacheWriteTokens: Long? = null,
|
||||
)
|
||||
|
||||
/** Why an agent turn ended. Wire values are snake_case; unknown maps to [UNKNOWN]. */
|
||||
@Serializable
|
||||
enum class StopReason {
|
||||
@SerialName("end_turn")
|
||||
END_TURN,
|
||||
|
||||
@SerialName("max_tokens")
|
||||
MAX_TOKENS,
|
||||
|
||||
@SerialName("cancelled")
|
||||
CANCELLED,
|
||||
|
||||
@SerialName("error")
|
||||
ERROR,
|
||||
|
||||
@SerialName("unknown")
|
||||
UNKNOWN,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String): StopReason =
|
||||
when (value) {
|
||||
"end_turn" -> END_TURN
|
||||
"max_tokens" -> MAX_TOKENS
|
||||
"cancelled" -> CANCELLED
|
||||
"error" -> ERROR
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.amTurnMetrics
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
|
||||
fun TagArrayBuilder<AgentTurnMetricEvent>.owner(ownerPubKey: HexKey) = addUnique(PTag.assemble(ownerPubKey, null))
|
||||
|
||||
fun TagArrayBuilder<AgentTurnMetricEvent>.agent(agentPubKey: HexKey) = addUnique(AgentTag.assemble(agentPubKey))
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.amTurnMetrics
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
|
||||
/** The owner (recipient) pubkey — the single `p` tag. */
|
||||
fun TagArray.turnMetricOwner() = firstNotNullOfOrNull(PTag::parseKey)
|
||||
|
||||
/** The publishing agent's pubkey — the `agent` tag. */
|
||||
fun TagArray.turnMetricAgent() = firstNotNullOfOrNull(AgentTag::parse)
|
||||
@@ -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.amTurnMetrics.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 Buzz `agent` tag — carries the publishing agent's public key. Used by the agent
|
||||
* observability/accounting kinds (NIP-AM `kind:44200`, NIP-AO `kind:24200`) to name the
|
||||
* agent independently of the event author, and normally equals the event `pubkey`.
|
||||
*/
|
||||
object AgentTag {
|
||||
const val TAG_NAME = "agent"
|
||||
|
||||
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(agentPubKey: HexKey) = arrayOf(TAG_NAME, agentPubKey)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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.aoObserver
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
|
||||
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.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* A Buzz Agent Observer Frame (NIP-AO, `kind:24200`): a transient, owner-scoped,
|
||||
* encrypted agent telemetry or control message. Ephemeral — relays route it on
|
||||
* its cleartext tags and never store it.
|
||||
*
|
||||
* Tags are all cleartext so a relay can route without reading ACP internals:
|
||||
* - `p` — the recipient ([recipientPubKey]); the owner for telemetry, the agent for control.
|
||||
* - `agent` — the agent whose observer stream this frame belongs to ([agentPubKey]).
|
||||
* - `frame` — the direction ([frame]): `telemetry` (agent-to-owner) or `control` (owner-to-agent).
|
||||
*
|
||||
* The `content` is a NIP-44 v2 ciphertext between the two parties. Because the
|
||||
* conversation key is symmetric either party can [decryptTelemetry] /
|
||||
* [decryptControl]. Ground truth: `buzz-core/src/observer.rs`,
|
||||
* `buzz-sdk/src/builders.rs` (`build_agent_observer_frame`), and the relay
|
||||
* routing in `buzz-relay/src/handlers/event.rs` (`agent_observer_route`).
|
||||
*/
|
||||
@Immutable
|
||||
class ObserverFrameEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
/** The recipient pubkey — the `p` tag (owner for telemetry, agent for control). */
|
||||
fun recipientPubKey() = tags.observerRecipient()
|
||||
|
||||
/** The agent this observer stream belongs to — the `agent` tag. */
|
||||
fun agentPubKey() = tags.observerAgent()
|
||||
|
||||
/** The cleartext frame direction — the `frame` tag (`telemetry` or `control`). */
|
||||
fun frame() = tags.observerFrame()
|
||||
|
||||
/**
|
||||
* Resolves the NIP-44 counterparty for [signer]: if the signer authored the
|
||||
* event, it is the `p` recipient; otherwise it is the author. Either party
|
||||
* to the frame holds the same symmetric conversation key.
|
||||
*/
|
||||
private fun counterparty(signer: NostrSigner): HexKey? = if (signer.pubKey == pubKey) recipientPubKey() else pubKey
|
||||
|
||||
/**
|
||||
* Decrypts and parses a `frame:telemetry` body. [signer] must belong to the
|
||||
* agent (author) or the owner (`p` recipient). Throws on a missing
|
||||
* counterparty, decryption failure, or malformed payload.
|
||||
*/
|
||||
suspend fun decryptTelemetry(signer: NostrSigner): ObserverTelemetryPayload {
|
||||
val counterparty = counterparty(signer)
|
||||
requireNotNull(counterparty) { "Observer frame is missing the recipient (p) tag" }
|
||||
val json = signer.decrypt(content, counterparty)
|
||||
return ObserverTelemetryPayload.decodeFromJson(json)
|
||||
}
|
||||
|
||||
suspend fun decryptTelemetryOrNull(signer: NostrSigner): ObserverTelemetryPayload? =
|
||||
try {
|
||||
decryptTelemetry(signer)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts and parses a `frame:control` body. [signer] must belong to the
|
||||
* owner (author) or the agent (`p` recipient). Throws on a missing
|
||||
* counterparty, decryption failure, or malformed payload.
|
||||
*/
|
||||
suspend fun decryptControl(signer: NostrSigner): ObserverControlPayload {
|
||||
val counterparty = counterparty(signer)
|
||||
requireNotNull(counterparty) { "Observer frame is missing the recipient (p) tag" }
|
||||
val json = signer.decrypt(content, counterparty)
|
||||
return ObserverControlPayload.decodeFromJson(json)
|
||||
}
|
||||
|
||||
suspend fun decryptControlOrNull(signer: NostrSigner): ObserverControlPayload? =
|
||||
try {
|
||||
decryptControl(signer)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 24200
|
||||
|
||||
/**
|
||||
* Builds and signs an agent-to-owner telemetry frame: [signer] is the
|
||||
* agent, the payload is NIP-44-encrypted to [ownerPubKey], the `p` tag is
|
||||
* the owner, and the `frame` tag is [FrameTag.TELEMETRY].
|
||||
*/
|
||||
suspend fun createTelemetry(
|
||||
payload: ObserverTelemetryPayload,
|
||||
ownerPubKey: HexKey,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): ObserverFrameEvent {
|
||||
val ciphertext = signer.nip44Encrypt(payload.encodeToJson(), ownerPubKey)
|
||||
return signer.sign(build(ciphertext, ownerPubKey, signer.pubKey, FrameTag.TELEMETRY, createdAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and signs an owner-to-agent control frame: [signer] is the owner,
|
||||
* the payload is NIP-44-encrypted to [agentPubKey], the `p` tag is the
|
||||
* agent, and the `frame` tag is [FrameTag.CONTROL].
|
||||
*/
|
||||
suspend fun createControl(
|
||||
payload: ObserverControlPayload,
|
||||
agentPubKey: HexKey,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): ObserverFrameEvent {
|
||||
val ciphertext = signer.nip44Encrypt(payload.encodeToJson(), agentPubKey)
|
||||
return signer.sign(build(ciphertext, agentPubKey, agentPubKey, FrameTag.CONTROL, createdAt))
|
||||
}
|
||||
|
||||
fun build(
|
||||
ciphertext: String,
|
||||
recipientPubKey: HexKey,
|
||||
agentPubKey: HexKey,
|
||||
frame: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ObserverFrameEvent>.() -> Unit = {},
|
||||
) = eventTemplate<ObserverFrameEvent>(KIND, ciphertext, createdAt) {
|
||||
recipient(recipientPubKey)
|
||||
agent(agentPubKey)
|
||||
frame(frame)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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.aoObserver
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* The decrypted plaintext of an agent observer frame (NIP-AO, `kind:24200`).
|
||||
*
|
||||
* Two shapes travel over the same NIP-44 v2 ciphertext; the cleartext `frame`
|
||||
* tag ([com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag]) says which:
|
||||
* - [ObserverTelemetryPayload] for `frame:telemetry` (agent-to-owner).
|
||||
* - [ObserverControlPayload] for `frame:control` (owner-to-agent).
|
||||
*/
|
||||
object ObserverPayload {
|
||||
val JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-to-owner telemetry frame body. Mirrors `ObserverEvent` in
|
||||
* `buzz-acp/src/observer.rs` (serde `rename_all = "camelCase"`), one entry from
|
||||
* the harness's in-process observer bus.
|
||||
*
|
||||
* [seq] is a process-local monotonic counter, [timestamp] is RFC3339 UTC, and
|
||||
* [kind] is a semantic label such as `acp_read`, `turn_started`, or
|
||||
* `control_result`. Every id/context field is optional. [payload] is the raw
|
||||
* or semantic event body, kept as an opaque [JsonElement] because its schema
|
||||
* varies per [kind].
|
||||
*/
|
||||
@Serializable
|
||||
data class ObserverTelemetryPayload(
|
||||
val seq: Long,
|
||||
val timestamp: String,
|
||||
val kind: String,
|
||||
val agentIndex: Int? = null,
|
||||
val channelId: String? = null,
|
||||
val sessionId: String? = null,
|
||||
val turnId: String? = null,
|
||||
val startedAt: String? = null,
|
||||
val payload: JsonElement? = null,
|
||||
) {
|
||||
fun encodeToJson(): String = ObserverPayload.JSON.encodeToString(this)
|
||||
|
||||
companion object {
|
||||
fun decodeFromJson(json: String): ObserverTelemetryPayload = ObserverPayload.JSON.decodeFromString(json)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-to-agent control frame body. The relay never reads this — it routes on
|
||||
* the cleartext `frame:control` tag — so the schema is defined only by the
|
||||
* agent-side reader in `buzz-acp/src/lib.rs` (`handle_relay_observer_control_event`),
|
||||
* which dispatches on [type] and pulls [channelId] / [modelId] ad hoc.
|
||||
*
|
||||
* Known [type] values are [CANCEL_TURN] and [SWITCH_MODEL]; an unrecognized
|
||||
* value is retained verbatim (the agent silently ignores it) rather than
|
||||
* dropping the payload.
|
||||
*/
|
||||
@Serializable
|
||||
data class ObserverControlPayload(
|
||||
@SerialName("type") val type: String,
|
||||
val channelId: String? = null,
|
||||
val modelId: String? = null,
|
||||
) {
|
||||
fun encodeToJson(): String = ObserverPayload.JSON.encodeToString(this)
|
||||
|
||||
companion object {
|
||||
const val CANCEL_TURN = "cancel_turn"
|
||||
const val SWITCH_MODEL = "switch_model"
|
||||
|
||||
fun decodeFromJson(json: String): ObserverControlPayload = ObserverPayload.JSON.decodeFromString(json)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.aoObserver
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
|
||||
fun TagArrayBuilder<ObserverFrameEvent>.recipient(recipientPubKey: HexKey) = addUnique(PTag.assemble(recipientPubKey, null))
|
||||
|
||||
fun TagArrayBuilder<ObserverFrameEvent>.agent(agentPubKey: HexKey) = addUnique(AgentTag.assemble(agentPubKey))
|
||||
|
||||
fun TagArrayBuilder<ObserverFrameEvent>.frame(frame: String) = addUnique(FrameTag.assemble(frame))
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.aoObserver
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag
|
||||
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
|
||||
/** The frame recipient pubkey — the single `p` tag (owner for telemetry, agent for control). */
|
||||
fun TagArray.observerRecipient() = firstNotNullOfOrNull(PTag::parseKey)
|
||||
|
||||
/** The agent this observer stream belongs to — the `agent` tag. */
|
||||
fun TagArray.observerAgent() = firstNotNullOfOrNull(AgentTag::parse)
|
||||
|
||||
/** The cleartext frame direction — the `frame` tag (`telemetry` or `control`). */
|
||||
fun TagArray.observerFrame() = firstNotNullOfOrNull(FrameTag::parse)
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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.aoObserver.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The Buzz `frame` tag — the cleartext direction of an agent observer frame
|
||||
* (NIP-AO `kind:24200`). It is the only routing hint a relay reads without
|
||||
* touching the NIP-44 ciphertext:
|
||||
* - [TELEMETRY] — agent-to-owner telemetry (author is the agent, `p` is the owner).
|
||||
* - [CONTROL] — owner-to-agent control commands (author is the owner, `p` is the agent).
|
||||
*
|
||||
* Ground truth: `buzz-core/src/observer.rs` (`OBSERVER_FRAME_TAG`,
|
||||
* `OBSERVER_FRAME_TELEMETRY`, `OBSERVER_FRAME_CONTROL`).
|
||||
*/
|
||||
object FrameTag {
|
||||
const val TAG_NAME = "frame"
|
||||
|
||||
/** Agent-to-owner telemetry frame value. */
|
||||
const val TELEMETRY = "telemetry"
|
||||
|
||||
/** Owner-to-agent control frame value. */
|
||||
const val CONTROL = "control"
|
||||
|
||||
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(frame: String) = arrayOf(TAG_NAME, frame)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* The JSON body stored in a Buzz Persona event ([PersonaEvent], NIP-AP `kind:30175`).
|
||||
*
|
||||
* This is the on-the-wire projection published by the workspace owner — ground truth is
|
||||
* `PersonaEventContent` in Buzz's `desktop/src-tauri/src/managed_agents/persona_events.rs`
|
||||
* (NOT `PersonaConfig` in `buzz-persona/src/persona.rs`, which is the local `.persona.md`
|
||||
* YAML parser and is never serialized to a Nostr event). Field declaration order is fixed
|
||||
* upstream because serde emits in order and that order pins the content bytes (and thus the
|
||||
* NIP-01 event id + the persona content hash); it is mirrored here.
|
||||
*
|
||||
* [displayName] is required; every other field is optional. Wire field names are snake_case
|
||||
* (the Rust struct carries no `rename_all`, so the raw identifiers are the wire names) and are
|
||||
* mapped with [SerialName]. [respondTo] is a free-form string here (the persona's default
|
||||
* author-gate mode), unlike the managed-agent projection which uses a closed enum. Unknown JSON
|
||||
* fields are ignored for forward compatibility.
|
||||
*/
|
||||
@Serializable
|
||||
data class PersonaContent(
|
||||
@SerialName("display_name") val displayName: String,
|
||||
@SerialName("system_prompt") val systemPrompt: String? = null,
|
||||
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||
val runtime: String? = null,
|
||||
val model: String? = null,
|
||||
val provider: String? = null,
|
||||
// NEVER-encode the empty defaults to match Rust's `skip_serializing_if = Vec::is_empty`
|
||||
// (persona_events.rs): emitting `[]` where upstream omits the field would shift the
|
||||
// content bytes and spuriously trip the desktop's persona_content_hash drift check.
|
||||
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("name_pool") val namePool: List<String> = emptyList(),
|
||||
@SerialName("respond_to") val respondTo: String? = null,
|
||||
@EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("respond_to_allowlist") val respondToAllowlist: List<String> = emptyList(),
|
||||
val parallelism: Int? = null,
|
||||
) {
|
||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||
|
||||
companion object {
|
||||
val JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun decodeFromJson(json: String): PersonaContent = JSON.decodeFromString(json)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
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.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* A Buzz Agent Persona (NIP-AP, `kind:30175`): an addressable, world-readable persona
|
||||
* definition published by the workspace owner. Addressed by `(pubkey, 30175, d)` where the
|
||||
* `d` tag is the plaintext persona slug (grammar `^[a-z0-9][a-z0-9_-]{0,63}$`, enforced by
|
||||
* the relay in `buzz-relay/src/handlers/ingest.rs::validate_persona_envelope`).
|
||||
*
|
||||
* The `content` is a plaintext JSON [PersonaContent]. Ground truth for the content projection
|
||||
* is `desktop/src-tauri/src/managed_agents/persona_events.rs`.
|
||||
*/
|
||||
@Immutable
|
||||
class PersonaEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The persona slug — the `d` tag. */
|
||||
fun slug() = dTag()
|
||||
|
||||
/** Parses the persona configuration, or throws if the JSON is malformed. */
|
||||
fun persona(): PersonaContent = PersonaContent.decodeFromJson(content)
|
||||
|
||||
fun personaOrNull(): PersonaContent? =
|
||||
try {
|
||||
persona()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 30175
|
||||
|
||||
fun build(
|
||||
persona: PersonaContent,
|
||||
slug: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<PersonaEvent>.() -> Unit = {},
|
||||
) = eventTemplate<PersonaEvent>(KIND, persona.encodeToJson(), createdAt) {
|
||||
dTag(slug)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.audit
|
||||
|
||||
/**
|
||||
* The action recorded by a Buzz audit entry, mirroring `AuditAction` in
|
||||
* `buzz-audit/src/action.rs` (snake_case wire values). Unknown values map to [UNKNOWN] so a
|
||||
* future action string round-trips rather than being dropped.
|
||||
*/
|
||||
enum class AuditAction(
|
||||
val code: String,
|
||||
) {
|
||||
EVENT_CREATED("event_created"),
|
||||
EVENT_DELETED("event_deleted"),
|
||||
CHANNEL_CREATED("channel_created"),
|
||||
CHANNEL_UPDATED("channel_updated"),
|
||||
CHANNEL_DELETED("channel_deleted"),
|
||||
MEMBER_ADDED("member_added"),
|
||||
MEMBER_REMOVED("member_removed"),
|
||||
AUTH_SUCCESS("auth_success"),
|
||||
AUTH_FAILURE("auth_failure"),
|
||||
RATE_LIMIT_EXCEEDED("rate_limit_exceeded"),
|
||||
MEDIA_UPLOADED("media_uploaded"),
|
||||
|
||||
/** An unrecognized action string from a future/extended implementation. */
|
||||
UNKNOWN("unknown"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String): AuditAction = entries.firstOrNull { it.code == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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.audit
|
||||
|
||||
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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz audit-log entry (`kind:48001`): a record of one moderated/administrative action.
|
||||
*
|
||||
* SCHEMA INFERRED — FLAGGED. The Rust source (`buzz-core/src/kind.rs`) reserves this kind and
|
||||
* lists it in a metrics-label allowlist (`buzz-relay/src/handlers/event.rs::bounded_kind_label`),
|
||||
* but the codebase NEVER constructs a `kind:48001` Nostr event: the audit trail is a
|
||||
* server-side hash-chained DB structure (`buzz-audit/src/entry.rs::AuditEntry`), not a wire
|
||||
* event. The tag/content mapping below is a Quartz-side projection of that struct — `content`
|
||||
* holds the arbitrary JSON `detail`, an `action` tag holds the [AuditAction], an optional `p`
|
||||
* tag names the actor, and an optional `object` tag holds the object id. Do NOT assume this is
|
||||
* registrable or interoperable until the Buzz side actually emits this kind on the wire.
|
||||
*/
|
||||
@Immutable
|
||||
class AuditEntryEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The recorded action — the `action` tag. */
|
||||
fun action(): AuditAction? = tags.auditAction()
|
||||
|
||||
/** The actor pubkey — the `p` tag, if the action has one. */
|
||||
fun actor(): HexKey? = tags.auditActor()
|
||||
|
||||
/** The identifier of the object acted upon — the `object` tag, if any. */
|
||||
fun objectId(): String? = tags.auditObjectId()
|
||||
|
||||
/** The arbitrary JSON `detail` context — the event `content`. */
|
||||
fun detail(): String = content
|
||||
|
||||
companion object {
|
||||
const val KIND = 48001
|
||||
|
||||
fun build(
|
||||
action: AuditAction,
|
||||
detail: String,
|
||||
actorPubKey: HexKey? = null,
|
||||
objectId: String? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<AuditEntryEvent>.() -> Unit = {},
|
||||
) = eventTemplate<AuditEntryEvent>(KIND, detail, createdAt) {
|
||||
action(action)
|
||||
actorPubKey?.let { actor(it) }
|
||||
objectId?.let { objectId(it) }
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.audit
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.audit.tags.ActionTag
|
||||
import com.vitorpamplona.quartz.buzz.audit.tags.ObjectTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
|
||||
fun TagArrayBuilder<AuditEntryEvent>.action(action: AuditAction) = addUnique(ActionTag.assemble(action))
|
||||
|
||||
fun TagArrayBuilder<AuditEntryEvent>.actor(actorPubKey: HexKey) = addUnique(PTag.assemble(actorPubKey, null))
|
||||
|
||||
fun TagArrayBuilder<AuditEntryEvent>.objectId(objectId: String) = addUnique(ObjectTag.assemble(objectId))
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.audit
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.audit.tags.ActionTag
|
||||
import com.vitorpamplona.quartz.buzz.audit.tags.ObjectTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
|
||||
/** The audit action — the `action` tag. */
|
||||
fun TagArray.auditAction() = firstNotNullOfOrNull(ActionTag::parse)
|
||||
|
||||
/** The actor pubkey — the `p` tag. */
|
||||
fun TagArray.auditActor() = firstNotNullOfOrNull(PTag::parseKey)
|
||||
|
||||
/** The identifier of the object acted upon — the `object` tag. */
|
||||
fun TagArray.auditObjectId() = firstNotNullOfOrNull(ObjectTag::parse)
|
||||
@@ -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.audit.tags
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.audit.AuditAction
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The Buzz `action` tag — the audit action recorded on an
|
||||
* [com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent]. SCHEMA INFERRED (see the event's
|
||||
* KDoc): the Rust never constructs a `kind:48001` event, so this tag's name is a Quartz-side
|
||||
* choice made to expose the `AuditAction` (`buzz-audit/src/action.rs`) at the tag level.
|
||||
*/
|
||||
object ActionTag {
|
||||
const val TAG_NAME = "action"
|
||||
|
||||
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||
|
||||
fun parse(tag: Tag): AuditAction? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return AuditAction.fromWire(tag[1])
|
||||
}
|
||||
|
||||
fun assemble(action: AuditAction) = arrayOf(TAG_NAME, action.code)
|
||||
}
|
||||
@@ -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.audit.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The Buzz `object` tag — the generic identifier of the object an audit entry acted upon
|
||||
* (event id hex, channel UUID, media sha256, …), mirroring `AuditEntry.object_id` in
|
||||
* `buzz-audit/src/entry.rs`. SCHEMA INFERRED (see [com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent]).
|
||||
*/
|
||||
object ObjectTag {
|
||||
const val TAG_NAME = "object"
|
||||
|
||||
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(objectId: String) = arrayOf(TAG_NAME, objectId)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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-summary overlay (`kind:39005`).
|
||||
*
|
||||
* Field names are snake_case on the wire, mirroring the object the relay synthesizes in
|
||||
* `buzz-relay/src/api/bridge.rs`: [replyCount] direct replies, [descendantCount] total
|
||||
* events in the thread, [lastReplyAt] the newest reply's unix-seconds timestamp (null when
|
||||
* there are no replies), and [participants] the hex pubkeys that posted in the thread.
|
||||
*/
|
||||
@Serializable
|
||||
data class ThreadSummaryContent(
|
||||
@SerialName("reply_count") val replyCount: Long,
|
||||
@SerialName("descendant_count") val descendantCount: Long,
|
||||
@SerialName("last_reply_at") val lastReplyAt: Long? = null,
|
||||
val participants: List<String> = emptyList(),
|
||||
) {
|
||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||
|
||||
companion object {
|
||||
val JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun decodeFromJson(json: String): ThreadSummaryContent = JSON.decodeFromString(json)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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.events.ETag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz NIP-CW thread-summary overlay (`kind:39005`), **signed by the relay** and
|
||||
* synthesized at query time (never stored). Appended to a bridge window response, one per
|
||||
* root event that has replies. Addressable with `d` = the root event id; it also `e`-tags
|
||||
* the same root and `h`-tags the channel. `content` is a [ThreadSummaryContent] JSON blob.
|
||||
* Clients never publish this kind; [build] exists for fixtures/tests. Ground truth:
|
||||
* `buzz-relay/src/api/bridge.rs` (thread-summary overlays).
|
||||
*/
|
||||
@Immutable
|
||||
class ThreadSummaryEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The thread root event id — the `d` tag (equal to the `e` tag). */
|
||||
fun rootId() = dTag()
|
||||
|
||||
/** The channel id — the `h` tag. */
|
||||
fun channelId() = tags.firstNotNullOfOrNull(GroupIdTag::parse)
|
||||
|
||||
/** The root event id from the `e` tag. */
|
||||
fun rootEventTag() = tags.firstNotNullOfOrNull(ETag::parseId)
|
||||
|
||||
/** Parses the JSON summary [content]. Throws on malformed content; use [summaryOrNull]. */
|
||||
fun summary(): ThreadSummaryContent = ThreadSummaryContent.decodeFromJson(content)
|
||||
|
||||
fun summaryOrNull(): ThreadSummaryContent? =
|
||||
try {
|
||||
summary()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 39005
|
||||
|
||||
fun build(
|
||||
rootId: HexKey,
|
||||
channelId: String,
|
||||
summary: ThreadSummaryContent,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ThreadSummaryEvent>.() -> Unit = {},
|
||||
) = eventTemplate<ThreadSummaryEvent>(KIND, summary.encodeToJson(), createdAt) {
|
||||
addUnique(ETag.assemble(rootId, null, null))
|
||||
dTag(rootId)
|
||||
addUnique(GroupIdTag.assemble(channelId))
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.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 window-bounds overlay (`kind:39006`) — the sole
|
||||
* authority on window exhaustion. Mirrors `buzz-relay/src/api/bridge.rs`: [hasMore] is
|
||||
* whether more rows exist beyond this page, and [nextCursor] is the cursor to request them
|
||||
* (null when exhausted). Clients MUST NOT infer `has_more` from row counts.
|
||||
*/
|
||||
@Serializable
|
||||
data class WindowBoundsContent(
|
||||
@SerialName("has_more") val hasMore: Boolean,
|
||||
@SerialName("next_cursor") val nextCursor: NextCursor? = null,
|
||||
) {
|
||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||
|
||||
companion object {
|
||||
val JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun decodeFromJson(json: String): WindowBoundsContent = JSON.decodeFromString(json)
|
||||
}
|
||||
}
|
||||
|
||||
/** A window pagination cursor: the [createdAt] unix-seconds timestamp and hex event [id] of the boundary row. */
|
||||
@Serializable
|
||||
data class NextCursor(
|
||||
@SerialName("created_at") val createdAt: Long,
|
||||
val id: String,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz NIP-CW window-bounds overlay (`kind:39006`), **signed by the relay** and
|
||||
* synthesized at query time (never stored). Exactly one is appended per window response and
|
||||
* it is the only authority on exhaustion. Addressable with `d` = `<channel_id>:<cursor|head>`
|
||||
* and `h`-tags the channel; `content` is a [WindowBoundsContent] JSON blob. Clients never
|
||||
* publish this kind; [build] exists for fixtures/tests. Ground truth:
|
||||
* `buzz-relay/src/api/bridge.rs` (window bounds).
|
||||
*/
|
||||
@Immutable
|
||||
class WindowBoundsEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The window key — the `d` tag, formatted `<channel_id>:<cursor-or-head>`. */
|
||||
fun windowKey() = dTag()
|
||||
|
||||
/** The channel id — the `h` tag. */
|
||||
fun channelId() = tags.firstNotNullOfOrNull(GroupIdTag::parse)
|
||||
|
||||
/** Parses the JSON bounds [content]. Throws on malformed content; use [boundsOrNull]. */
|
||||
fun bounds(): WindowBoundsContent = WindowBoundsContent.decodeFromJson(content)
|
||||
|
||||
fun boundsOrNull(): WindowBoundsContent? =
|
||||
try {
|
||||
bounds()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 39006
|
||||
|
||||
/** The `d`-tag value for a window: `<channelId>:<cursor>`, where `cursor` is `head` for the first page. */
|
||||
fun windowKey(
|
||||
channelId: String,
|
||||
cursor: String = "head",
|
||||
) = "$channelId:$cursor"
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
bounds: WindowBoundsContent,
|
||||
cursor: String = "head",
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<WindowBoundsEvent>.() -> Unit = {},
|
||||
) = eventTemplate<WindowBoundsEvent>(KIND, bounds.encodeToJson(), createdAt) {
|
||||
dTag(windowKey(channelId, cursor))
|
||||
addUnique(GroupIdTag.assemble(channelId))
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.dm
|
||||
|
||||
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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz "add member to DM" command (`kind:41011`): adds a new participant to an
|
||||
* existing group DM. Scopes the target conversation with the `h` (NIP-29 group id =
|
||||
* DM channel UUID) tag and names the invited member with a single `p` tag; the
|
||||
* `content` is empty.
|
||||
*
|
||||
* Ground truth: `buzz-sdk/src/builders.rs` (`build_dm_add_member`) and
|
||||
* `buzz-cli/src/commands/dms.rs` (`cmd_add_dm_member`).
|
||||
*/
|
||||
@Immutable
|
||||
class DmAddMemberEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The target DM channel id - the `h` tag. */
|
||||
fun channelId() = tags.dmChannelId()
|
||||
|
||||
/** The member being added - the single `p` tag. */
|
||||
fun member() = tags.dmParticipants().firstOrNull()
|
||||
|
||||
companion object {
|
||||
const val KIND = 41011
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
member: HexKey,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<DmAddMemberEvent>.() -> Unit = {},
|
||||
) = eventTemplate<DmAddMemberEvent>(KIND, "", createdAt) {
|
||||
dmChannel(channelId)
|
||||
dmMember(member)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.dm
|
||||
|
||||
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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz "DM created" confirmation (`kind:41001`): the **relay-signed** record of a
|
||||
* materialized DM conversation. Clients list their DMs by querying `kind:41001`
|
||||
* filtered by their own `#p`; each event carries the DM id in a `d` tag and one `p`
|
||||
* tag per participant.
|
||||
*
|
||||
* This is a relay-authored sidecar, not a client-published command - clients react to
|
||||
* [DmOpenEvent]/[DmAddMemberEvent] by emitting the command, and the relay confirms
|
||||
* with this event. [build] is provided for tests/tooling parity; in production the
|
||||
* relay is the signer. Ground truth: `buzz-cli/src/commands/dms.rs` (`cmd_list_dms`,
|
||||
* which reads the `d` tag as `dm_id` and the `p` tags as participants) and
|
||||
* `buzz-core/src/kind.rs` (`KIND_DM_CREATED`).
|
||||
*/
|
||||
@Immutable
|
||||
class DmCreatedEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The DM id - the `d` tag (empty string when absent). */
|
||||
fun dmId() = tags.dmId()
|
||||
|
||||
/** The DM participants - one per `p` tag. */
|
||||
fun participants() = tags.dmParticipants()
|
||||
|
||||
companion object {
|
||||
const val KIND = 41001
|
||||
|
||||
fun build(
|
||||
dmId: String,
|
||||
participants: List<HexKey>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<DmCreatedEvent>.() -> Unit = {},
|
||||
) = eventTemplate<DmCreatedEvent>(KIND, "", createdAt) {
|
||||
dmId(dmId)
|
||||
participants(participants)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.dm
|
||||
|
||||
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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz "hide DM" command (`kind:41012`): hides a DM conversation from the author's
|
||||
* sidebar. Carries only the `h` (NIP-29 group id = DM channel UUID) tag scoping the
|
||||
* conversation to hide; the `content` is empty.
|
||||
*
|
||||
* Ground truth: `buzz-cli/src/commands/dms.rs` (`cmd_hide_dm`) and
|
||||
* `buzz-relay/src/handlers/command_executor.rs` (`handle_dm_hide`).
|
||||
*/
|
||||
@Immutable
|
||||
class DmHideEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The DM channel id being hidden - the `h` tag. */
|
||||
fun channelId() = tags.dmChannelId()
|
||||
|
||||
companion object {
|
||||
const val KIND = 41012
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<DmHideEvent>.() -> Unit = {},
|
||||
) = eventTemplate<DmHideEvent>(KIND, "", createdAt) {
|
||||
dmChannel(channelId)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.dm
|
||||
|
||||
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.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz "open DM" command (`kind:41010`): opens (or re-opens) a direct-message
|
||||
* conversation between the author and 1-8 named participants. The participants are
|
||||
* carried as one `p` tag each and the `content` is empty - the relay executes the
|
||||
* command, materializes the DM channel, and confirms it back with a relay-signed
|
||||
* [DmCreatedEvent] (`kind:41001`).
|
||||
*
|
||||
* Ground truth: `buzz-sdk/src/builders.rs` (`build_dm_open`) and
|
||||
* `buzz-relay/src/handlers/command_executor.rs`.
|
||||
*/
|
||||
@Immutable
|
||||
class DmOpenEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The DM participants - one per `p` tag. */
|
||||
fun participants() = tags.dmParticipants()
|
||||
|
||||
companion object {
|
||||
const val KIND = 41010
|
||||
|
||||
/** Buzz enforces 1-8 participants for a DM open command. */
|
||||
const val MIN_PARTICIPANTS = 1
|
||||
const val MAX_PARTICIPANTS = 8
|
||||
|
||||
fun build(
|
||||
participants: List<HexKey>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<DmOpenEvent>.() -> Unit = {},
|
||||
): EventTemplate<DmOpenEvent> {
|
||||
require(participants.size in MIN_PARTICIPANTS..MAX_PARTICIPANTS) {
|
||||
"A DM open requires $MIN_PARTICIPANTS-$MAX_PARTICIPANTS participants (got ${participants.size})"
|
||||
}
|
||||
return eventTemplate(KIND, "", createdAt) {
|
||||
participants(participants)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.dm
|
||||
|
||||
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.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
|
||||
/** One `p` tag per DM participant. */
|
||||
fun <T : Event> TagArrayBuilder<T>.participants(pubKeys: List<HexKey>) = addAll(pubKeys.map { PTag.assemble(it, null) })
|
||||
|
||||
/** The `h` (NIP-29 group id = DM channel UUID) tag. */
|
||||
fun <T : Event> TagArrayBuilder<T>.dmChannel(channelId: String) = addUnique(GroupIdTag.assemble(channelId))
|
||||
|
||||
/** A single `p` tag for the member being added. */
|
||||
fun <T : Event> TagArrayBuilder<T>.dmMember(pubKey: HexKey) = addUnique(PTag.assemble(pubKey, null))
|
||||
|
||||
/** The `d` tag carrying the DM id (relay-signed [DmCreatedEvent]). */
|
||||
fun <T : Event> TagArrayBuilder<T>.dmId(dmId: String) = addUnique(DTag.assemble(dmId))
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.dm
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
|
||||
/** All DM participant pubkeys - every `p` tag. */
|
||||
fun TagArray.dmParticipants() = mapNotNull(PTag::parseKey)
|
||||
|
||||
/** The DM channel id - the first `h` tag. */
|
||||
fun TagArray.dmChannelId() = firstNotNullOfOrNull(GroupIdTag::parse)
|
||||
|
||||
/** The DM id - the `d` tag value (empty string when absent). */
|
||||
fun TagArray.dmId() = firstTagValue(DTag.TAG_NAME) ?: ""
|
||||
@@ -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.quartz.buzz.dvDmVisibility
|
||||
|
||||
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.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz per-viewer DM Visibility snapshot (NIP-DV, `kind:30622`): a relay-signed,
|
||||
* addressable event that projects one viewer's hidden-DM set. It is keyed by the viewer's
|
||||
* pubkey (the `d` tag), carries a matching `p` tag so the relay's `#p`-gated read path
|
||||
* scopes it to its owner, and lists one `h` tag per hidden DM channel (zero or more). The
|
||||
* content is empty; the relay re-publishes the whole snapshot on every hide/unhide.
|
||||
*
|
||||
* The event is signed by the relay keypair, not the viewer. Ground truth:
|
||||
* `buzz-core/src/kind.rs` (`KIND_DM_VISIBILITY`) and
|
||||
* `buzz-relay/src/handlers/side_effects.rs` (`publish_dm_visibility_snapshot`).
|
||||
*/
|
||||
@Immutable
|
||||
class DmVisibilityEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The viewer this snapshot belongs to — the `d` tag (equal to the `p` tag). */
|
||||
fun viewer() = dTag()
|
||||
|
||||
/** The viewer pubkey read from the `p` tag. */
|
||||
fun viewerFromPTag() = tags.dmVisibilityViewer()
|
||||
|
||||
/** The channel ids the viewer has hidden — the `h` tags (possibly empty). */
|
||||
fun hiddenChannels() = tags.dmVisibilityHiddenChannels()
|
||||
|
||||
companion object {
|
||||
const val KIND = 30622
|
||||
|
||||
/**
|
||||
* Builds a DM-visibility snapshot template for [viewerPubKey] listing
|
||||
* [hiddenChannels]. The content is empty. Sign it with the relay keypair.
|
||||
*/
|
||||
fun build(
|
||||
viewerPubKey: HexKey,
|
||||
hiddenChannels: List<String>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<DmVisibilityEvent>.() -> Unit = {},
|
||||
) = eventTemplate<DmVisibilityEvent>(KIND, "", createdAt) {
|
||||
dTag(viewerPubKey)
|
||||
viewer(viewerPubKey)
|
||||
hiddenChannels(hiddenChannels)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.dvDmVisibility
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
|
||||
fun TagArrayBuilder<DmVisibilityEvent>.viewer(viewerPubKey: HexKey) = addUnique(PTag.assemble(viewerPubKey, null))
|
||||
|
||||
fun TagArrayBuilder<DmVisibilityEvent>.hiddenChannel(channelId: String) = add(GroupIdTag.assemble(channelId))
|
||||
|
||||
fun TagArrayBuilder<DmVisibilityEvent>.hiddenChannels(channelIds: List<String>) = addAll(channelIds.map(GroupIdTag::assemble))
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.dvDmVisibility
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
|
||||
/** The viewer pubkey from the `p` tag, or null if absent. */
|
||||
fun TagArray.dmVisibilityViewer() = firstNotNullOfOrNull(PTag::parseKey)
|
||||
|
||||
/** The hidden DM channel ids — every `h` tag (possibly empty). */
|
||||
fun TagArray.dmVisibilityHiddenChannels() = mapNotNull(GroupIdTag::parse)
|
||||
@@ -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.erReminders
|
||||
|
||||
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.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* A Buzz Event Reminder (NIP-ER, `kind:30300`): an encrypted, author-only, addressable
|
||||
* reminder keyed by `(pubkey, 30300, d)`.
|
||||
*
|
||||
* The public [notBefore] tag tells supporting relays when the reminder is due; the
|
||||
* reminder [target], `note`, and `status` are NIP-44 encrypted to the author's own
|
||||
* public key (the same self-encryption pattern as NIP-51 private lists), so [decrypt]
|
||||
* needs the author's signer. A reminder without `not_before` is a bookmark or a terminal
|
||||
* (done/cancelled) state. Ground truth: `buzz-core/src/kind.rs` (`KIND_EVENT_REMINDER`)
|
||||
* and `buzz-relay/src/handlers/ingest.rs` (`validate_event_reminder`).
|
||||
*/
|
||||
@Immutable
|
||||
class EventReminderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
/** The public due-time hint (unix seconds), or null for bookmarks/terminal states. */
|
||||
fun notBefore() = tags.reminderNotBefore()
|
||||
|
||||
/** NIP-40 cleanup expiration (unix seconds), when set. */
|
||||
fun expiration() = tags.expiration()
|
||||
|
||||
/**
|
||||
* Decrypts and parses the reminder payload. [signer] MUST be the author (this event's
|
||||
* own key), because the content is self-encrypted. Throws on a decryption failure or a
|
||||
* malformed payload; use [decryptOrNull] to swallow those.
|
||||
*/
|
||||
suspend fun decrypt(signer: NostrSigner): EventReminderPayload {
|
||||
val json = signer.decrypt(content, pubKey)
|
||||
return EventReminderPayload.decodeFromJson(json)
|
||||
}
|
||||
|
||||
suspend fun decryptOrNull(signer: NostrSigner): EventReminderPayload? =
|
||||
try {
|
||||
decrypt(signer)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 30300
|
||||
const val ALT_DESCRIPTION = "Encrypted reminder"
|
||||
|
||||
/**
|
||||
* Builds and signs a reminder: [signer] is the author; [payload] is NIP-44
|
||||
* self-encrypted to the author's own key. [identifier] is the addressable `d`
|
||||
* tag and MUST be an opaque random value (>= 128 bits of entropy).
|
||||
*/
|
||||
suspend fun create(
|
||||
payload: EventReminderPayload,
|
||||
identifier: HexKey,
|
||||
signer: NostrSigner,
|
||||
notBefore: Long? = null,
|
||||
expiration: Long? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): EventReminderEvent {
|
||||
val ciphertext = signer.nip44Encrypt(payload.encodeToJson(), signer.pubKey)
|
||||
return signer.sign(build(ciphertext, identifier, notBefore, expiration, createdAt))
|
||||
}
|
||||
|
||||
fun build(
|
||||
ciphertext: String,
|
||||
identifier: String,
|
||||
notBefore: Long? = null,
|
||||
expiration: Long? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<EventReminderEvent>.() -> Unit = {},
|
||||
) = eventTemplate<EventReminderEvent>(KIND, ciphertext, createdAt) {
|
||||
dTag(identifier)
|
||||
notBefore?.let { notBefore(it) }
|
||||
expiration?.let { expiration(it) }
|
||||
alt(ALT_DESCRIPTION)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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.erReminders
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* The decrypted plaintext of a Buzz Event Reminder (NIP-ER, `kind:30300`).
|
||||
*
|
||||
* Field names mirror the NIP-ER content object exactly (all lowercase on the wire).
|
||||
* A pending reminder MUST carry either a [target] reference or a non-empty [note];
|
||||
* both are optional here so a note-only or a target-only reminder round-trips. Unknown
|
||||
* JSON fields are ignored for forward compatibility, and an unrecognized [status] value
|
||||
* decodes to null via [statusOrNull] rather than dropping the payload.
|
||||
*/
|
||||
@Serializable
|
||||
data class EventReminderPayload(
|
||||
val target: ReminderTarget? = null,
|
||||
val status: String,
|
||||
val note: String? = null,
|
||||
) {
|
||||
/** Maps the wire [status] to a [ReminderStatus], or null if unrecognized. */
|
||||
fun statusOrNull(): ReminderStatus? = ReminderStatus.fromWire(status)
|
||||
|
||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||
|
||||
companion object {
|
||||
val JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun decodeFromJson(json: String): EventReminderPayload = JSON.decodeFromString(json)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to what the reminder is about. Every field is optional: `a` is the preferred
|
||||
* addressable coordinate, `id` a snapshot fallback event id, `relays` are hints only, and
|
||||
* `preview` a cached label. Mirrors the `target` object in NIP-ER content.
|
||||
*/
|
||||
@Serializable
|
||||
data class ReminderTarget(
|
||||
val id: String? = null,
|
||||
val a: String? = null,
|
||||
val relays: List<String>? = null,
|
||||
val preview: String? = null,
|
||||
)
|
||||
|
||||
/** Reminder lifecycle status. Wire values are lowercase; unknown decodes to null. */
|
||||
enum class ReminderStatus {
|
||||
@SerialName("pending")
|
||||
PENDING,
|
||||
|
||||
@SerialName("done")
|
||||
DONE,
|
||||
|
||||
@SerialName("cancelled")
|
||||
CANCELLED,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String): ReminderStatus? =
|
||||
when (value) {
|
||||
"pending" -> PENDING
|
||||
"done" -> DONE
|
||||
"cancelled" -> CANCELLED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.erReminders
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.erReminders.tags.NotBeforeTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
|
||||
fun TagArrayBuilder<EventReminderEvent>.notBefore(unixSeconds: Long) = addUnique(NotBeforeTag.assemble(unixSeconds))
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.erReminders
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.erReminders.tags.NotBeforeTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
|
||||
/** The public `not_before` due-time hint (unix seconds), or null if none is present. */
|
||||
fun TagArray.reminderNotBefore() = firstNotNullOfOrNull(NotBeforeTag::parse)
|
||||
@@ -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.erReminders.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* The Buzz `not_before` tag (NIP-ER) — the public due-time hint of an encrypted reminder,
|
||||
* a decimal Unix-seconds timestamp. A supporting relay reads it to schedule the due signal
|
||||
* without decrypting the reminder. At most one is allowed; terminal states and bookmarks
|
||||
* omit it. Ground truth: `buzz-relay/src/handlers/ingest.rs` (`validate_not_before`).
|
||||
*/
|
||||
object NotBeforeTag {
|
||||
const val TAG_NAME = "not_before"
|
||||
|
||||
fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME
|
||||
|
||||
fun parse(tag: Tag): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(unixSeconds: Long) = arrayOf(TAG_NAME, unixSeconds.toString())
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.forum
|
||||
|
||||
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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A comment reply on a Buzz forum post (`kind:45003`). The `content` is the reply body,
|
||||
* an `h` tag scopes it to the channel, NIP-10 `e` tags (`root`/`reply` markers) place it
|
||||
* in the thread, and optional `p` tags mention members. Ground truth: `build_forum_comment`
|
||||
* + `thread_tags` in Buzz's `buzz-sdk/src/builders.rs`.
|
||||
*/
|
||||
@Immutable
|
||||
class ForumCommentEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The channel UUID (the `h` tag) this comment belongs to. */
|
||||
fun channel() = tags.forumChannel()
|
||||
|
||||
/** The thread root event id (`root`-marked `e` tag), or null for a direct reply. */
|
||||
fun threadRoot() = tags.forumThreadRoot()
|
||||
|
||||
/** The immediate-parent event id (`reply`-marked `e` tag). */
|
||||
fun replyTo() = tags.forumThreadReply()
|
||||
|
||||
/** The pubkeys mentioned by this comment (`p` tags). */
|
||||
fun mentions() = tags.forumMentions()
|
||||
|
||||
/** The comment body - the event `content`. */
|
||||
fun body() = content
|
||||
|
||||
companion object {
|
||||
const val KIND = 45003
|
||||
|
||||
/**
|
||||
* Builds a forum comment. When [rootEventId] equals [parentEventId] the comment is a
|
||||
* direct reply to the thread root; otherwise it is a nested reply - see [forumThread].
|
||||
*/
|
||||
fun build(
|
||||
channelId: String,
|
||||
body: String,
|
||||
rootEventId: HexKey,
|
||||
parentEventId: HexKey,
|
||||
mentions: List<HexKey> = emptyList(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ForumCommentEvent>.() -> Unit = {},
|
||||
) = eventTemplate<ForumCommentEvent>(KIND, body, createdAt) {
|
||||
forumChannel(channelId)
|
||||
forumThread(rootEventId, parentEventId)
|
||||
forumMentions(mentions)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.forum
|
||||
|
||||
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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz forum post - the root of a forum thread (`kind:45001`). The `content` is the
|
||||
* post body (plaintext, max 64 KiB). An `h` tag scopes it to a channel; optional `p`
|
||||
* tags mention channel members. Ground truth: `build_forum_post` in Buzz's
|
||||
* `buzz-sdk/src/builders.rs`.
|
||||
*/
|
||||
@Immutable
|
||||
class ForumPostEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The channel UUID (the `h` tag) this post belongs to. */
|
||||
fun channel() = tags.forumChannel()
|
||||
|
||||
/** The pubkeys mentioned by this post (`p` tags). */
|
||||
fun mentions() = tags.forumMentions()
|
||||
|
||||
/** The post body - the event `content`. */
|
||||
fun body() = content
|
||||
|
||||
companion object {
|
||||
const val KIND = 45001
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
body: String,
|
||||
mentions: List<HexKey> = emptyList(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ForumPostEvent>.() -> Unit = {},
|
||||
) = eventTemplate<ForumPostEvent>(KIND, body, createdAt) {
|
||||
forumChannel(channelId)
|
||||
forumMentions(mentions)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.forum
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.buzz.forum.tags.VoteDirection
|
||||
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 vote on a Buzz forum post (`kind:45002`). The `content` is `"+"` (up) or `"-"` (down),
|
||||
* an `h` tag scopes it to the channel, and an `e` tag names the target event. Ground truth:
|
||||
* `build_vote` in Buzz's `buzz-sdk/src/builders.rs`.
|
||||
*/
|
||||
@Immutable
|
||||
class ForumVoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The channel UUID (the `h` tag) this vote belongs to. */
|
||||
fun channel() = tags.forumChannel()
|
||||
|
||||
/** The `e` target event this vote applies to. */
|
||||
fun target() = tags.forumVoteTarget()
|
||||
|
||||
/** The vote direction parsed from `content` (`"+"`/`"-"`), or null if malformed. */
|
||||
fun direction() = VoteDirection.fromContent(content)
|
||||
|
||||
companion object {
|
||||
const val KIND = 45002
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
targetEventId: HexKey,
|
||||
direction: VoteDirection,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ForumVoteEvent>.() -> Unit = {},
|
||||
) = eventTemplate<ForumVoteEvent>(KIND, direction.code, createdAt) {
|
||||
forumChannel(channelId)
|
||||
forumVoteTarget(targetEventId)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.forum
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||
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.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
|
||||
/** The `h` channel tag (NIP-29) scoping a Buzz forum event to its channel UUID. */
|
||||
fun <T : Event> TagArrayBuilder<T>.forumChannel(channelId: String) = addUnique(GroupIdTag.assemble(channelId))
|
||||
|
||||
/** A `p` mention per pubkey, deduplicated. Mirrors `mention_tags` in `builders.rs`. */
|
||||
fun <T : Event> TagArrayBuilder<T>.forumMentions(mentions: List<HexKey>) = mentions.forEach { addUniqueValueIfNew(PTag.assemble(it, null)) }
|
||||
|
||||
/** The `e` tag naming a forum vote's target event. */
|
||||
fun <T : Event> TagArrayBuilder<T>.forumVoteTarget(targetEventId: HexKey) = addUnique(ETag.assemble(targetEventId, null, null))
|
||||
|
||||
/**
|
||||
* NIP-10 thread e-tags for a forum comment. Buzz threads streams and forum comments
|
||||
* identically (`thread_tags` in `builders.rs`), so this delegates to the shared
|
||||
* [buzzThread] verb.
|
||||
*/
|
||||
fun <T : Event> TagArrayBuilder<T>.forumThread(
|
||||
rootEventId: HexKey,
|
||||
parentEventId: HexKey,
|
||||
) = buzzThread(rootEventId, parentEventId)
|
||||
@@ -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.forum
|
||||
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
|
||||
/** The `h` channel UUID this forum event belongs to. */
|
||||
fun TagArray.forumChannel(): String? = firstTagValue(GroupIdTag.TAG_NAME)
|
||||
|
||||
/** The `p` mentions carried by a forum post/comment. */
|
||||
fun TagArray.forumMentions(): List<HexKey> = mapNotNull(PTag::parseKey)
|
||||
|
||||
/** The `e` target event of a forum vote (`kind:45002`). */
|
||||
fun TagArray.forumVoteTarget(): HexKey? = firstNotNullOfOrNull(ETag::parseId)
|
||||
|
||||
/** The NIP-10 `root`-marked thread event id of a forum comment (null for a direct reply). */
|
||||
fun TagArray.forumThreadRoot(): HexKey? = buzzThreadRoot()
|
||||
|
||||
/** The NIP-10 `reply`-marked immediate-parent event id of a forum comment. */
|
||||
fun TagArray.forumThreadReply(): HexKey? = buzzThreadReply()
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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.forum.tags
|
||||
|
||||
/**
|
||||
* The direction of a Buzz forum vote (`kind:45002`). The vote's on-the-wire form is the
|
||||
* event `content`: `"+"` for [UP], `"-"` for [DOWN]. Ground truth: `build_vote` in Buzz's
|
||||
* `buzz-sdk/src/builders.rs`.
|
||||
*/
|
||||
enum class VoteDirection(
|
||||
val code: String,
|
||||
) {
|
||||
UP("+"),
|
||||
DOWN("-"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Parses the vote [content] (`"+"`/`"-"`) into a [VoteDirection], or null if unrecognized. */
|
||||
fun fromContent(content: String): VoteDirection? =
|
||||
when (content) {
|
||||
UP.code -> UP
|
||||
DOWN.code -> DOWN
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz "huddle ended" event (`kind:48103`): the audio session ended (typically emitted
|
||||
* relay-signed when the room empties and auto-archives). Scoped to the parent channel by an
|
||||
* `h` tag; `content` names the ephemeral audio channel, and a `p` tag names the last
|
||||
* participant. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
|
||||
*/
|
||||
@Immutable
|
||||
class HuddleEndedEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The parent (timeline) channel UUID — the `h` tag. */
|
||||
fun channelId(): String? = tags.huddleChannel()
|
||||
|
||||
/** The participant named on the ending event — the `p` tag, if present. */
|
||||
fun participant(): HexKey? = tags.huddleParticipant()
|
||||
|
||||
/** The ephemeral audio channel id carried in `content`. */
|
||||
fun ephemeralChannelId(): String? = HuddleLifecycleContent.decodeFromJsonOrNull(content)?.ephemeralChannelId
|
||||
|
||||
companion object {
|
||||
const val KIND = 48103
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
ephemeralChannelId: String,
|
||||
participant: HexKey? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<HuddleEndedEvent>.() -> Unit = {},
|
||||
) = eventTemplate<HuddleEndedEvent>(KIND, HuddleLifecycleContent(ephemeralChannelId).encodeToJson(), createdAt) {
|
||||
huddleChannel(channelId)
|
||||
participant?.let { huddleParticipant(it) }
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz huddle/channel guidelines document (`kind:48106`): the rules text that agents in a
|
||||
* channel are steered by (system messages moved to this kind from inline `[System]`-prefixed
|
||||
* chat). Scoped to the channel by an `h` tag; the guidelines text lives in `content`.
|
||||
*
|
||||
* SCHEMA UNCERTAIN: the Rust source only wires this kind's scope/validation
|
||||
* (`buzz-relay/src/handlers/ingest.rs` — ChannelsWrite, `h` required); it never constructs a
|
||||
* `kind:48106` event, so the exact content shape (plain text vs JSON) is inferred from the
|
||||
* TTS reference in `desktop/.../useTtsSubscription.ts`. Treated here as plain-text `content`.
|
||||
*/
|
||||
@Immutable
|
||||
class HuddleGuidelinesEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The channel UUID these guidelines apply to — the `h` tag. */
|
||||
fun channelId(): String? = tags.huddleChannel()
|
||||
|
||||
/** The guidelines text — the event `content`. */
|
||||
fun guidelines(): String = content
|
||||
|
||||
companion object {
|
||||
const val KIND = 48106
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
guidelines: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<HuddleGuidelinesEvent>.() -> Unit = {},
|
||||
) = eventTemplate<HuddleGuidelinesEvent>(KIND, guidelines, createdAt) {
|
||||
huddleChannel(channelId)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 shared by every Buzz huddle-lifecycle event (`kind:48100`–`48103`):
|
||||
* 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
|
||||
* the wire. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
|
||||
*/
|
||||
@Serializable
|
||||
data class HuddleLifecycleContent(
|
||||
@SerialName("ephemeral_channel_id") val ephemeralChannelId: String,
|
||||
) {
|
||||
fun encodeToJson(): String = JSON.encodeToString(this)
|
||||
|
||||
companion object {
|
||||
val JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun decodeFromJson(json: String): HuddleLifecycleContent = JSON.decodeFromString(json)
|
||||
|
||||
fun decodeFromJsonOrNull(json: String): HuddleLifecycleContent? =
|
||||
try {
|
||||
decodeFromJson(json)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz "participant joined" event (`kind:48101`): a participant joined a huddle. Emitted
|
||||
* relay-signed, so the actual participant is named by the `p` tag (not the event `pubkey`).
|
||||
* Scoped to the parent channel by an `h` tag; `content` names the ephemeral audio channel.
|
||||
* Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
|
||||
*/
|
||||
@Immutable
|
||||
class HuddleParticipantJoinedEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The parent (timeline) channel UUID — the `h` tag. */
|
||||
fun channelId(): String? = tags.huddleChannel()
|
||||
|
||||
/** The participant who joined — the `p` tag. */
|
||||
fun participant(): HexKey? = tags.huddleParticipant()
|
||||
|
||||
/** The ephemeral audio channel id carried in `content`. */
|
||||
fun ephemeralChannelId(): String? = HuddleLifecycleContent.decodeFromJsonOrNull(content)?.ephemeralChannelId
|
||||
|
||||
companion object {
|
||||
const val KIND = 48101
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
ephemeralChannelId: String,
|
||||
participant: HexKey,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<HuddleParticipantJoinedEvent>.() -> Unit = {},
|
||||
) = eventTemplate<HuddleParticipantJoinedEvent>(KIND, HuddleLifecycleContent(ephemeralChannelId).encodeToJson(), createdAt) {
|
||||
huddleChannel(channelId)
|
||||
huddleParticipant(participant)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz "participant left" event (`kind:48102`): a participant left a huddle. Emitted
|
||||
* relay-signed, so the actual participant is named by the `p` tag (not the event `pubkey`).
|
||||
* Scoped to the parent channel by an `h` tag; `content` names the ephemeral audio channel.
|
||||
* Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`.
|
||||
*/
|
||||
@Immutable
|
||||
class HuddleParticipantLeftEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The parent (timeline) channel UUID — the `h` tag. */
|
||||
fun channelId(): String? = tags.huddleChannel()
|
||||
|
||||
/** The participant who left — the `p` tag. */
|
||||
fun participant(): HexKey? = tags.huddleParticipant()
|
||||
|
||||
/** The ephemeral audio channel id carried in `content`. */
|
||||
fun ephemeralChannelId(): String? = HuddleLifecycleContent.decodeFromJsonOrNull(content)?.ephemeralChannelId
|
||||
|
||||
companion object {
|
||||
const val KIND = 48102
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
ephemeralChannelId: String,
|
||||
participant: HexKey,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<HuddleParticipantLeftEvent>.() -> Unit = {},
|
||||
) = eventTemplate<HuddleParticipantLeftEvent>(KIND, HuddleLifecycleContent(ephemeralChannelId).encodeToJson(), createdAt) {
|
||||
huddleChannel(channelId)
|
||||
huddleParticipant(participant)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.quartz.buzz.huddles
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.buzz.huddles.reaction.reaction
|
||||
import com.vitorpamplona.quartz.buzz.huddles.reaction.reactionChannel
|
||||
import com.vitorpamplona.quartz.buzz.huddles.reaction.reactionCustomEmojis
|
||||
import com.vitorpamplona.quartz.buzz.huddles.reaction.reactionSenderName
|
||||
import com.vitorpamplona.quartz.buzz.huddles.reaction.senderName
|
||||
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 huddle emoji reaction burst (`kind:24810`): a transient emoji animation shown to
|
||||
* everyone in an ephemeral huddle channel. Ephemeral (20000–29999) — never stored. The emoji
|
||||
* is in `content` and mirrored in a `reaction` tag; the burst is scoped to the huddle channel
|
||||
* by an `h` tag, carries a `sender_name` display name, and may carry a NIP-30 `emoji` tag for
|
||||
* a custom emoji. Ground truth: `desktop/.../huddle/components/HuddleBar.tsx`.
|
||||
*/
|
||||
@Immutable
|
||||
class HuddleReactionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The ephemeral huddle channel UUID — the `h` tag. */
|
||||
fun channelId(): String? = tags.reactionChannel()
|
||||
|
||||
/** The emoji — the `reaction` tag, or the `content` fallback. */
|
||||
fun emoji(): String = tags.reaction() ?: content
|
||||
|
||||
/** The sender's display name — the `sender_name` tag. */
|
||||
fun senderName(): String? = tags.reactionSenderName()
|
||||
|
||||
/** Any NIP-30 custom-emoji descriptors carried on the burst. */
|
||||
fun customEmojis() = tags.reactionCustomEmojis()
|
||||
|
||||
companion object {
|
||||
const val KIND = 24810
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
emoji: String,
|
||||
senderName: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<HuddleReactionEvent>.() -> Unit = {},
|
||||
) = eventTemplate<HuddleReactionEvent>(KIND, emoji, createdAt) {
|
||||
reactionChannel(channelId)
|
||||
reaction(emoji)
|
||||
senderName(senderName)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* A Buzz "huddle started" event (`kind:48100`): the creator announces a new audio
|
||||
* session in a channel. Signed by the creator (the participant is the event `pubkey`),
|
||||
* scoped to the parent channel by an `h` tag, with `content` naming the ephemeral audio
|
||||
* channel. Ground truth: `buzz-relay/src/audio/handler.rs`, `buzz-relay/src/handlers/ingest.rs`.
|
||||
*/
|
||||
@Immutable
|
||||
class HuddleStartedEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** The parent (timeline) channel UUID — the `h` tag. */
|
||||
fun channelId(): String? = tags.huddleChannel()
|
||||
|
||||
/** The ephemeral audio channel id carried in `content`. */
|
||||
fun ephemeralChannelId(): String? = HuddleLifecycleContent.decodeFromJsonOrNull(content)?.ephemeralChannelId
|
||||
|
||||
companion object {
|
||||
const val KIND = 48100
|
||||
|
||||
fun build(
|
||||
channelId: String,
|
||||
ephemeralChannelId: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<HuddleStartedEvent>.() -> Unit = {},
|
||||
) = eventTemplate<HuddleStartedEvent>(KIND, HuddleLifecycleContent(ephemeralChannelId).encodeToJson(), createdAt) {
|
||||
huddleChannel(channelId)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 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.people.PTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
|
||||
/** Scopes a huddle event to its parent (timeline) channel — the `h` tag. */
|
||||
fun <T : Event> TagArrayBuilder<T>.huddleChannel(channelId: String) = addUnique(GroupIdTag.assemble(channelId))
|
||||
|
||||
/** Names the participant a huddle lifecycle event is about — the `p` tag. */
|
||||
fun <T : Event> TagArrayBuilder<T>.huddleParticipant(pubKey: HexKey) = addUnique(PTag.assemble(pubKey, null))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user