From d2c9593919be2e50fd2239bb9db63f57833cd99c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:11:04 +0000 Subject: [PATCH] refactor: extract AccountRelayGroupActions from Account Moves the ~460-line NIP-29 relay-group + Buzz workspace orchestration (join/leave/create/delete/archive, threads, invites, pins, member/role management, metadata edits, Buzz DMs/jobs/workflows/typing, community member add/remove) into AccountRelayGroupActions, exposed as account.relayGroups. External callers now use account.relayGroups.* directly. Moved code is unchanged except for account. qualification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA --- .../vitorpamplona/amethyst/model/Account.kt | 506 +--------------- .../model/AccountRelayGroupActions.kt | 549 ++++++++++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 36 +- .../loggedIn/buzz/AgentWorkBoardViewModel.kt | 12 +- .../screen/loggedIn/buzz/BuzzDmListScreen.kt | 2 +- .../loggedIn/buzz/BuzzDmListViewModel.kt | 4 +- .../loggedIn/buzz/BuzzNewDmViewModel.kt | 2 +- .../screen/loggedIn/buzz/JobBoardViewModel.kt | 6 +- .../buzz/WorkflowRunBoardViewModel.kt | 8 +- .../relayGroup/RelayGroupMetadataViewModel.kt | 4 +- .../send/ChannelNewMessageViewModel.kt | 2 +- 11 files changed, 590 insertions(+), 541 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b86ecdf03d..d9c5c66860 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect -import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager @@ -47,10 +46,8 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState -import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache @@ -160,26 +157,9 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor -import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent -import com.vitorpamplona.quartz.buzz.dm.DmHideEvent -import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent -import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent -import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent -import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent -import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent -import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent 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.buzz.workflow.ApprovalDenyEvent -import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent -import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent -import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent -import com.vitorpamplona.quartz.buzz.workflow.workflowChannel -import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN -import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER -import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN -import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent @@ -214,10 +194,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -268,20 +245,8 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous -import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache @@ -332,7 +297,6 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag -import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag @@ -378,8 +342,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import java.math.BigDecimal import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash @@ -761,6 +723,9 @@ class Account( /** Marmot/MLS group orchestration (create/members/admins/messages/key packages). */ val marmot = AccountMarmotActions(this) + /** NIP-29 relay-group + Buzz workspace orchestration. */ + val relayGroups = AccountRelayGroupActions(this) + /** * Relay routing + sign-and-publish choke point: computes which relays an event * should go to (outbox model, hints, channels, broadcast lists) and owns every @@ -2081,471 +2046,6 @@ class Account( return (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") } - suspend fun joinRelayGroup( - channel: RelayGroupChannel, - code: String? = null, - ) { - val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - 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)) - } - - /** - * Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010 - * command. [participants] are the OTHER 1-8 people — the relay adds me, derives the - * canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent] - * (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry]. - * We never assign the channel id ourselves, so callers discover the materialized DM - * by watching that registry rather than from this call's return. - */ - suspend fun openBuzzDm( - relay: NormalizedRelayUrl, - participants: List, - ): String? { - val signed = signer.sign(DmOpenEvent.build(participants)) - // The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` — - // the authoritative, relay-assigned channel UUID (the deployed relay does not emit a - // queryable kind-41001). Read it straight from the ack so the caller can open the chat. - var results = client.publishAndCollectResults(signed, setOf(relay)) - var channelId = buzzDmChannelIdFromAck(results) - - // NIP-42 write race: on a cold connection the relay rejects the first publish with - // `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm - // the connection with a pendingOnAuthRequired read so the auth coordinator completes the - // handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix. - if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) { - client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))), - timeoutMs = 8_000, - pendingOnAuthRequired = true, - ) { _, _ -> false } - results = client.publishAndCollectResults(signed, setOf(relay)) - channelId = buzzDmChannelIdFromAck(results) - } - return channelId - } - - /** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */ - private fun buzzDmChannelIdFromAck(results: Map): String? = - results.values - .firstOrNull { it.accepted } - ?.message - ?.substringAfter("\"channel_id\":\"", "") - ?.substringBefore('"') - ?.takeIf { it.isNotBlank() } - - /** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */ - suspend fun hideBuzzDm(channel: RelayGroupChannel) { - val template = DmHideEvent.build(channel.groupId.id) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */ - suspend fun addBuzzDmMember( - channel: RelayGroupChannel, - member: HexKey, - ) { - val template = DmAddMemberEvent.build(channel.groupId.id, member) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared - * feature-request the workspace bot can pick up. Untargeted: any agent watching the - * channel may accept it. Returns the new job id (the request event id), or null when the - * account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator]. - */ - suspend fun fileBuzzJob( - relay: NormalizedRelayUrl, - channelId: String, - request: String, - ): HexKey? { - if (!isWriteable()) return null - val signed = signer.sign(JobRequestEvent.build(request, channelId, null)) - // Reflect it locally so the board updates immediately (publish only sends to relays). - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return signed.id - } - - /** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */ - suspend fun cancelBuzzJob( - relay: NormalizedRelayUrl, - channelId: String, - jobId: HexKey, - ) { - if (!isWriteable()) return - val signed = signer.sign(JobCancelEvent.build(jobId, "", channelId)) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - } - - /** - * Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on - * [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the - * approval token), returned here. A run pauses on a human-approval gate before anything ships — - * see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator]. - */ - suspend fun triggerBuzzWorkflow( - relay: NormalizedRelayUrl, - channelId: String, - workflowId: String, - task: String, - ): HexKey? { - if (!isWriteable()) return null - val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId)) - val signed = signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) }) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return signed.id - } - - /** - * Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an - * addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a - * human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses - * the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker - * offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write. - */ - suspend fun publishBuzzWorkflowDef( - relay: NormalizedRelayUrl, - channelId: String, - name: String, - yaml: String, - ): String? { - if (!isWriteable()) return null - val workflowId = RandomInstance.randomChars(16) - val signed = signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null })) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return workflowId - } - - /** - * Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which - * doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work. - * Publishing to the single group [relay]; the runner discovers the decision by author. - */ - suspend fun approveBuzzWorkflowRun( - relay: NormalizedRelayUrl, - runId: HexKey, - note: String = "", - ): HexKey? { - if (!isWriteable()) return null - val signed = signer.sign(ApprovalGrantEvent.build(runId, note)) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return signed.id - } - - /** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */ - suspend fun denyBuzzWorkflowRun( - relay: NormalizedRelayUrl, - runId: HexKey, - note: String = "", - ): HexKey? { - if (!isWriteable()) return null - val signed = signer.sign(ApprovalDenyEvent.build(runId, note)) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - return signed.id - } - - /** - * Upvote a Buzz job [jobId] (authored by [jobAuthor]) — a NIP-25 like (kind-7 `+`) `e`-tagging - * the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and - * `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority. - */ - suspend fun upvoteBuzzJob( - relay: NormalizedRelayUrl, - channelId: String, - jobId: HexKey, - jobAuthor: HexKey?, - ) { - if (!isWriteable()) return - val template = - eventTemplate(ReactionEvent.KIND, ReactionEvent.LIKE) { - addUnique(ETag.assemble(jobId, null, null)) - jobAuthor?.let { addUnique(PTag.assemble(it, null)) } - addUnique(arrayOf("k", JobRequestEvent.KIND.toString())) - addUnique(GroupIdTag.assemble(channelId)) - } - val signed = signer.sign(template) - cache.justConsumeMyOwnEvent(signed) - client.publish(signed, setOf(relay)) - } - - /** 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) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - unfollow(channel) - } - - /** - * Delete the whole group with a kind 9008 delete-group event (owner/admin only — the relay - * enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than - * just removing me; the relay drops the group and its messages. Also drops it from our own list - * so it disappears from Messages immediately instead of lingering as a now-dead id. - */ - suspend fun deleteRelayGroup(channel: RelayGroupChannel) { - val template = DeleteGroupEvent.build(channel.groupId.id) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - unfollow(channel) - // Remember the deletion so the channel leaves the community's browse list immediately and - // stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a - // stale re-announced 44100 on a Buzz relay) would otherwise keep it visible. - RelayGroupDeletions.markDeleted(channel.groupId) - } - - /** - * Create a new group on [relay]: kind 9007 (create-group) then kind 9002 - * (edit-metadata) with the chosen name/visibility, then remember it. Returns - * the new group's id. - */ - suspend fun createRelayGroup( - relay: NormalizedRelayUrl, - groupId: String, - name: String, - about: String? = null, - picture: String? = null, - isPrivate: Boolean = false, - isClosed: Boolean = false, - isHidden: Boolean = false, - isRestricted: Boolean = false, - hashtags: List = emptyList(), - geohashes: List = emptyList(), - parent: String? = null, - channelType: String? = null, - ): GroupId { - // The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes - // its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without - // a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay - // publish two events and produce nothing at all. - signAndSendPrivatelyOrBroadcast( - CreateGroupEvent.build( - groupId = groupId, - name = name, - about = about, - visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN, - channelType = channelType, - ), - ) { listOf(relay) } - - val edit = - EditMetadataEvent.build( - groupId, - name = name, - about = about, - picture = picture, - status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), - hashtags = hashtags, - geohashes = geohashes, - parent = parent, - ) - signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } - - val id = GroupId(groupId, relay) - follow(LocalCache.getOrCreateRelayGroupChannel(id)) - return id - } - - /** - * The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are - * presence-only — public/open/visible/unrestricted are simply the ABSENCE of their - * restrictive counterpart — so only the enabled restrictive flags are added. - */ - private fun relayGroupStatus( - isPrivate: Boolean, - isClosed: Boolean, - isHidden: Boolean, - isRestricted: Boolean, - ): Set = - buildSet { - if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE) - if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED) - if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN) - if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED) - } - - /** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */ - suspend fun postRelayGroupThread( - channel: RelayGroupChannel, - title: String, - body: String, - ) { - val template = - ThreadEvent.build(body, title) { - hTag(channel.groupId.id) - previous(channel.previousEventRefs(pubKey)) - } - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** Mint a kind 9009 invite code for the group (admin/moderator only). */ - suspend fun createRelayGroupInvite( - channel: RelayGroupChannel, - code: String, - ) { - val template = CreateInviteEvent.build(channel.groupId.id, code) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * Replace the group's pinned-message list with a kind 9010 update-pin-list event - * (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and - * republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. - */ - suspend fun updateRelayGroupPins( - channel: RelayGroupChannel, - pinnedEventIds: List, - ) { - val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** Pin [eventId] by appending it to the current list (no-op if already pinned). */ - suspend fun pinRelayGroupMessage( - channel: RelayGroupChannel, - eventId: HexKey, - ) { - if (channel.isPinned(eventId)) return - updateRelayGroupPins(channel, channel.pinnedEventIds + eventId) - } - - /** Unpin [eventId] by removing it from the current list (no-op if not pinned). */ - suspend fun unpinRelayGroupMessage( - channel: RelayGroupChannel, - eventId: HexKey, - ) { - if (!channel.isPinned(eventId)) return - updateRelayGroupPins(channel, channel.pinnedEventIds - eventId) - } - - /** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */ - suspend fun removeRelayGroupUser( - channel: RelayGroupChannel, - pubkey: HexKey, - ) { - val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey)) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * Add [pubkey] to the group (or change its roles) with a kind 9000 put-user - * event (moderator only). Pass an empty [roles] list for a plain member. - */ - suspend fun putRelayGroupUser( - channel: RelayGroupChannel, - pubkey: HexKey, - roles: List, - ) { - // Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its - // own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails - // the whole put-user, which is why an unmapped role must become `member` rather than travel. - val buzzRole = - if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { - when { - roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN - else -> BUZZ_ROLE_MEMBER - } - } else { - null - } - val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the - * relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the - * sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to - * [relay] with no channel scope. - */ - suspend fun addCommunityMember( - relay: NormalizedRelayUrl, - pubkey: HexKey, - role: String? = null, - ) { - signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) } - } - - /** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */ - suspend fun removeCommunityMember( - relay: NormalizedRelayUrl, - pubkey: HexKey, - ) { - signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) } - } - - /** - * Edit the group's relay-signed metadata with a kind 9002 event (admin only). - * - * NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy - * links: a 9002 with no `parent` tag re-roots the group, and one that drops any - * existing `child` is rejected by the relay. So unless the caller is explicitly - * re-parenting, we re-carry the group's current [parent] and full [children] list - * from its latest known metadata to keep the tree intact across a plain name/flag - * edit. Pass an explicit value to change them. - */ - suspend fun editRelayGroupMetadata( - channel: RelayGroupChannel, - name: String?, - about: String?, - picture: String?, - isPrivate: Boolean, - isClosed: Boolean, - isHidden: Boolean, - isRestricted: Boolean, - hashtags: List = emptyList(), - geohashes: List = emptyList(), - parent: String? = channel.parentGroupId(), - children: List = channel.childGroupIds(), - ) { - // On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT - // read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on - // edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag. - val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) - val template = - EditMetadataEvent.build( - channel.groupId.id, - name = name, - about = about, - picture = picture, - status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), - hashtags = hashtags, - geohashes = geohashes, - parent = parent, - children = children, - visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null, - ) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - - /** - * Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The - * relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its - * history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces. - */ - suspend fun archiveRelayGroup( - channel: RelayGroupChannel, - archived: Boolean, - ) { - val template = EditMetadataEvent.build(channel.groupId.id, archived = archived) - signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } - } - suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt new file mode 100644 index 0000000000..7816501a0b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -0,0 +1,549 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership +import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent +import com.vitorpamplona.quartz.buzz.dm.DmHideEvent +import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent +import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent +import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent +import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent +import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent +import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent +import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent +import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent +import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent +import com.vitorpamplona.quartz.buzz.workflow.workflowChannel +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous +import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * NIP-29 relay-group and Buzz-workspace orchestration for an [Account]: + * join/leave/create/delete/archive groups, threads, invites, pins, member and + * role management, metadata edits, plus the Buzz dialect's DMs, jobs, + * workflows, and typing signals. Event building lives in quartz builders; + * this class wires them to the account's signer and the group's host relay. + */ +class AccountRelayGroupActions( + private val account: Account, +) { + suspend fun joinRelayGroup( + channel: RelayGroupChannel, + code: String? = null, + ) { + val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + 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 (!account.isWriteable()) return + val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id)) + account.client.publish(signed, setOf(channel.groupId.relayUrl)) + } + + /** + * Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010 + * command. [participants] are the OTHER 1-8 people — the relay adds me, derives the + * canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent] + * (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry]. + * We never assign the channel id ourselves, so callers discover the materialized DM + * by watching that registry rather than from this call's return. + */ + suspend fun openBuzzDm( + relay: NormalizedRelayUrl, + participants: List, + ): String? { + val signed = account.signer.sign(DmOpenEvent.build(participants)) + // The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` — + // the authoritative, relay-assigned channel UUID (the deployed relay does not emit a + // queryable kind-41001). Read it straight from the ack so the caller can open the chat. + var results = account.client.publishAndCollectResults(signed, setOf(relay)) + var channelId = buzzDmChannelIdFromAck(results) + + // NIP-42 write race: on a cold connection the relay rejects the first publish with + // `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm + // the connection with a pendingOnAuthRequired read so the auth coordinator completes the + // handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix. + if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) { + account.client.fetchAllWithHooks( + filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))), + timeoutMs = 8_000, + pendingOnAuthRequired = true, + ) { _, _ -> false } + results = account.client.publishAndCollectResults(signed, setOf(relay)) + channelId = buzzDmChannelIdFromAck(results) + } + return channelId + } + + /** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */ + private fun buzzDmChannelIdFromAck(results: Map): String? = + results.values + .firstOrNull { it.accepted } + ?.message + ?.substringAfter("\"channel_id\":\"", "") + ?.substringBefore('"') + ?.takeIf { it.isNotBlank() } + + /** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */ + suspend fun hideBuzzDm(channel: RelayGroupChannel) { + val template = DmHideEvent.build(channel.groupId.id) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */ + suspend fun addBuzzDmMember( + channel: RelayGroupChannel, + member: HexKey, + ) { + val template = DmAddMemberEvent.build(channel.groupId.id, member) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared + * feature-request the workspace bot can pick up. Untargeted: any agent watching the + * channel may accept it. Returns the new job id (the request event id), or null when the + * account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator]. + */ + suspend fun fileBuzzJob( + relay: NormalizedRelayUrl, + channelId: String, + request: String, + ): HexKey? { + if (!account.isWriteable()) return null + val signed = account.signer.sign(JobRequestEvent.build(request, channelId, null)) + // Reflect it locally so the board updates immediately (publish only sends to relays). + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return signed.id + } + + /** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */ + suspend fun cancelBuzzJob( + relay: NormalizedRelayUrl, + channelId: String, + jobId: HexKey, + ) { + if (!account.isWriteable()) return + val signed = account.signer.sign(JobCancelEvent.build(jobId, "", channelId)) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + } + + /** + * Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on + * [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the + * approval token), returned here. A run pauses on a human-approval gate before anything ships — + * see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator]. + */ + suspend fun triggerBuzzWorkflow( + relay: NormalizedRelayUrl, + channelId: String, + workflowId: String, + task: String, + ): HexKey? { + if (!account.isWriteable()) return null + val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId)) + val signed = account.signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) }) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return signed.id + } + + /** + * Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an + * addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a + * human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses + * the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker + * offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write. + */ + suspend fun publishBuzzWorkflowDef( + relay: NormalizedRelayUrl, + channelId: String, + name: String, + yaml: String, + ): String? { + if (!account.isWriteable()) return null + val workflowId = RandomInstance.randomChars(16) + val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null })) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return workflowId + } + + /** + * Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which + * doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work. + * Publishing to the single group [relay]; the runner discovers the decision by author. + */ + suspend fun approveBuzzWorkflowRun( + relay: NormalizedRelayUrl, + runId: HexKey, + note: String = "", + ): HexKey? { + if (!account.isWriteable()) return null + val signed = account.signer.sign(ApprovalGrantEvent.build(runId, note)) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return signed.id + } + + /** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */ + suspend fun denyBuzzWorkflowRun( + relay: NormalizedRelayUrl, + runId: HexKey, + note: String = "", + ): HexKey? { + if (!account.isWriteable()) return null + val signed = account.signer.sign(ApprovalDenyEvent.build(runId, note)) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + return signed.id + } + + /** + * Upvote a Buzz job [jobId] (authored by [jobAuthor]) — a NIP-25 like (kind-7 `+`) `e`-tagging + * the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and + * `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority. + */ + suspend fun upvoteBuzzJob( + relay: NormalizedRelayUrl, + channelId: String, + jobId: HexKey, + jobAuthor: HexKey?, + ) { + if (!account.isWriteable()) return + val template = + eventTemplate(ReactionEvent.KIND, ReactionEvent.LIKE) { + addUnique(ETag.assemble(jobId, null, null)) + jobAuthor?.let { addUnique(PTag.assemble(it, null)) } + addUnique(arrayOf("k", JobRequestEvent.KIND.toString())) + addUnique(GroupIdTag.assemble(channelId)) + } + val signed = account.signer.sign(template) + account.cache.justConsumeMyOwnEvent(signed) + account.client.publish(signed, setOf(relay)) + } + + /** 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) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + account.unfollow(channel) + } + + /** + * Delete the whole group with a kind 9008 delete-group event (owner/admin only — the relay + * enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than + * just removing me; the relay drops the group and its messages. Also drops it from our own list + * so it disappears from Messages immediately instead of lingering as a now-dead id. + */ + suspend fun deleteRelayGroup(channel: RelayGroupChannel) { + val template = DeleteGroupEvent.build(channel.groupId.id) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + account.unfollow(channel) + // Remember the deletion so the channel leaves the community's browse list immediately and + // stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a + // stale re-announced 44100 on a Buzz relay) would otherwise keep it visible. + RelayGroupDeletions.markDeleted(channel.groupId) + } + + /** + * Create a new group on [relay]: kind 9007 (create-group) then kind 9002 + * (edit-metadata) with the chosen name/visibility, then remember it. Returns + * the new group's id. + */ + suspend fun createRelayGroup( + relay: NormalizedRelayUrl, + groupId: String, + name: String, + about: String? = null, + picture: String? = null, + isPrivate: Boolean = false, + isClosed: Boolean = false, + isHidden: Boolean = false, + isRestricted: Boolean = false, + hashtags: List = emptyList(), + geohashes: List = emptyList(), + parent: String? = null, + channelType: String? = null, + ): GroupId { + // The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes + // its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without + // a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay + // publish two events and produce nothing at all. + account.broadcaster.signAndSendPrivatelyOrBroadcast( + CreateGroupEvent.build( + groupId = groupId, + name = name, + about = about, + visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN, + channelType = channelType, + ), + ) { listOf(relay) } + + val edit = + EditMetadataEvent.build( + groupId, + name = name, + about = about, + picture = picture, + status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), + hashtags = hashtags, + geohashes = geohashes, + parent = parent, + ) + account.broadcaster.signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) } + + val id = GroupId(groupId, relay) + account.follow(LocalCache.getOrCreateRelayGroupChannel(id)) + return id + } + + /** + * The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are + * presence-only — public/open/visible/unrestricted are simply the ABSENCE of their + * restrictive counterpart — so only the enabled restrictive flags are added. + */ + private fun relayGroupStatus( + isPrivate: Boolean, + isClosed: Boolean, + isHidden: Boolean, + isRestricted: Boolean, + ): Set = + buildSet { + if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE) + if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED) + if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN) + if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED) + } + + /** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */ + suspend fun postRelayGroupThread( + channel: RelayGroupChannel, + title: String, + body: String, + ) { + val template = + ThreadEvent.build(body, title) { + hTag(channel.groupId.id) + previous(channel.previousEventRefs(account.pubKey)) + } + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** Mint a kind 9009 invite code for the group (admin/moderator only). */ + suspend fun createRelayGroupInvite( + channel: RelayGroupChannel, + code: String, + ) { + val template = CreateInviteEvent.build(channel.groupId.id, code) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Replace the group's pinned-message list with a kind 9010 update-pin-list event + * (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and + * republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. + */ + suspend fun updateRelayGroupPins( + channel: RelayGroupChannel, + pinnedEventIds: List, + ) { + val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** Pin [eventId] by appending it to the current list (no-op if already pinned). */ + suspend fun pinRelayGroupMessage( + channel: RelayGroupChannel, + eventId: HexKey, + ) { + if (channel.isPinned(eventId)) return + updateRelayGroupPins(channel, channel.pinnedEventIds + eventId) + } + + /** Unpin [eventId] by removing it from the current list (no-op if not pinned). */ + suspend fun unpinRelayGroupMessage( + channel: RelayGroupChannel, + eventId: HexKey, + ) { + if (!channel.isPinned(eventId)) return + updateRelayGroupPins(channel, channel.pinnedEventIds - eventId) + } + + /** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */ + suspend fun removeRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + ) { + val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey)) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Add [pubkey] to the group (or change its roles) with a kind 9000 put-user + * event (moderator only). Pass an empty [roles] list for a plain member. + */ + suspend fun putRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + roles: List, + ) { + // Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its + // own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails + // the whole put-user, which is why an unmapped role must become `member` rather than travel. + val buzzRole = + if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { + when { + roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN + else -> BUZZ_ROLE_MEMBER + } + } else { + null + } + val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the + * relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the + * sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to + * [relay] with no channel scope. + */ + suspend fun addCommunityMember( + relay: NormalizedRelayUrl, + pubkey: HexKey, + role: String? = null, + ) { + account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) } + } + + /** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */ + suspend fun removeCommunityMember( + relay: NormalizedRelayUrl, + pubkey: HexKey, + ) { + account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) } + } + + /** + * Edit the group's relay-signed metadata with a kind 9002 event (admin only). + * + * NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy + * links: a 9002 with no `parent` tag re-roots the group, and one that drops any + * existing `child` is rejected by the relay. So unless the caller is explicitly + * re-parenting, we re-carry the group's current [parent] and full [children] list + * from its latest known metadata to keep the tree intact across a plain name/flag + * edit. Pass an explicit value to change them. + */ + suspend fun editRelayGroupMetadata( + channel: RelayGroupChannel, + name: String?, + about: String?, + picture: String?, + isPrivate: Boolean, + isClosed: Boolean, + isHidden: Boolean, + isRestricted: Boolean, + hashtags: List = emptyList(), + geohashes: List = emptyList(), + parent: String? = channel.parentGroupId(), + children: List = channel.childGroupIds(), + ) { + // On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT + // read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on + // edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag. + val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) + val template = + EditMetadataEvent.build( + channel.groupId.id, + name = name, + about = about, + picture = picture, + status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted), + hashtags = hashtags, + geohashes = geohashes, + parent = parent, + children = children, + visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null, + ) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The + * relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its + * history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces. + */ + suspend fun archiveRelayGroup( + channel: RelayGroupChannel, + archived: Boolean, + ) { + val template = EditMetadataEvent.build(channel.groupId.id, archived = archived) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7a9981040a..3a24635536 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -696,7 +696,7 @@ class AccountViewModel( fun sendBuzzTyping(channel: RelayGroupChannel) = viewModelScope.launch(Dispatchers.IO) { - account.sendBuzzTyping(channel) + account.relayGroups.sendBuzzTyping(channel) } @Immutable @@ -1668,12 +1668,12 @@ class AccountViewModel( fun joinRelayGroup( channel: RelayGroupChannel, code: String? = null, - ) = launchSigner { account.joinRelayGroup(channel, code) } + ) = launchSigner { account.relayGroups.joinRelayGroup(channel, code) } - fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) } + fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.leaveRelayGroup(channel) } /** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */ - fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) } + fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.relayGroups.deleteRelayGroup(channel) } /** * Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without @@ -1682,7 +1682,7 @@ class AccountViewModel( fun archiveRelayGroup( channel: RelayGroupChannel, archived: Boolean, - ) = launchSigner { account.archiveRelayGroup(channel, archived) } + ) = launchSigner { account.relayGroups.archiveRelayGroup(channel, archived) } /** * Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops @@ -1721,7 +1721,7 @@ class AccountViewModel( * Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay * republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it. */ - fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) } + fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.relayGroups.hideBuzzDm(channel) } /** * Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with @@ -1731,7 +1731,7 @@ class AccountViewModel( fun unhideBuzzDm( relay: NormalizedRelayUrl, participants: List, - ) = launchSigner { account.openBuzzDm(relay, participants) } + ) = launchSigner { account.relayGroups.openBuzzDm(relay, participants) } /** * Keep the channel off Messages without touching membership. Local and reversible — I stay in the @@ -1745,7 +1745,7 @@ class AccountViewModel( /** Actually leave: kind-9022 to the host relay, and drop it from my list and the pending set. */ fun leaveChannelInvite(channel: RelayGroupChannel) = launchSigner { - account.leaveRelayGroup(channel) + account.relayGroups.leaveRelayGroup(channel) BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id) } @@ -1771,7 +1771,7 @@ class AccountViewModel( hashtags: List, geohashes: List, ) = launchSigner { - account.createRelayGroup( + account.relayGroups.createRelayGroup( relay, groupId, name, @@ -1789,47 +1789,47 @@ class AccountViewModel( fun createRelayGroupInvite( channel: RelayGroupChannel, code: String, - ) = launchSigner { account.createRelayGroupInvite(channel, code) } + ) = launchSigner { account.relayGroups.createRelayGroupInvite(channel, code) } fun postRelayGroupThread( channel: RelayGroupChannel, title: String, body: String, - ) = launchSigner { account.postRelayGroupThread(channel, title, body) } + ) = launchSigner { account.relayGroups.postRelayGroupThread(channel, title, body) } fun pinRelayGroupMessage( channel: RelayGroupChannel, note: Note, - ) = launchSigner { account.pinRelayGroupMessage(channel, note.idHex) } + ) = launchSigner { account.relayGroups.pinRelayGroupMessage(channel, note.idHex) } fun unpinRelayGroupMessage( channel: RelayGroupChannel, note: Note, - ) = launchSigner { account.unpinRelayGroupMessage(channel, note.idHex) } + ) = launchSigner { account.relayGroups.unpinRelayGroupMessage(channel, note.idHex) } fun removeRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, - ) = launchSigner { account.removeRelayGroupUser(channel, pubkey) } + ) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) } fun putRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, roles: List, - ) = launchSigner { account.putRelayGroupUser(channel, pubkey, roles) } + ) = launchSigner { account.relayGroups.putRelayGroupUser(channel, pubkey, roles) } /** Add [pubkey] to a Buzz community (relay-wide, kind 9030). Owner/admin only; relay enforces. */ fun addCommunityMember( relay: NormalizedRelayUrl, pubkey: HexKey, role: String? = null, - ) = launchSigner { account.addCommunityMember(relay, pubkey, role) } + ) = launchSigner { account.relayGroups.addCommunityMember(relay, pubkey, role) } /** Remove [pubkey] from a Buzz community (relay-wide, kind 9031). Owner/admin only. */ fun removeCommunityMember( relay: NormalizedRelayUrl, pubkey: HexKey, - ) = launchSigner { account.removeCommunityMember(relay, pubkey) } + ) = launchSigner { account.relayGroups.removeCommunityMember(relay, pubkey) } fun editRelayGroupMetadata( channel: RelayGroupChannel, @@ -1843,7 +1843,7 @@ class AccountViewModel( hashtags: List, geohashes: List, ) = launchSigner { - account.editRelayGroupMetadata( + account.relayGroups.editRelayGroupMetadata( channel, name, about, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt index 1aa34aaccf..e3f106f595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentWorkBoardViewModel.kt @@ -169,33 +169,33 @@ class AgentWorkBoardViewModel : ViewModel() { onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, channelId -> if (requireApproval) { - account.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null + account.relayGroups.triggerBuzzWorkflow(relay, channelId, ADHOC_WORKFLOW_ID, text) != null } else { - account.fileBuzzJob(relay, channelId, text) != null + account.relayGroups.fileBuzzJob(relay, channelId, text) != null } } fun approve( runId: HexKey, onResult: (Boolean) -> Unit, - ) = act(onResult) { account, relay, _ -> account.approveBuzzWorkflowRun(relay, runId) != null } + ) = act(onResult) { account, relay, _ -> account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null } fun deny( runId: HexKey, onResult: (Boolean) -> Unit, - ) = act(onResult) { account, relay, _ -> account.denyBuzzWorkflowRun(relay, runId) != null } + ) = act(onResult) { account, relay, _ -> account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null } fun upvote( jobId: HexKey, jobAuthor: HexKey?, ) = act({}) { account, relay, channelId -> - account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) + account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) true } fun cancel(jobId: HexKey) = act({}) { account, relay, channelId -> - account.cancelBuzzJob(relay, channelId, jobId) + account.relayGroups.cancelBuzzJob(relay, channelId, jobId) true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt index da30fcc4dc..008372414c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListScreen.kt @@ -211,7 +211,7 @@ private fun DmRowCard( addMemberOpen = false scope.launch { val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) - accountViewModel.account.addBuzzDmMember(channel, hex) + accountViewModel.account.relayGroups.addBuzzDmMember(channel, hex) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index 0e3427d717..a63b122d6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -254,7 +254,7 @@ class BuzzDmListViewModel : ViewModel() { fun removeFromMessages(row: DmRow) { val account = account ?: return viewModelScope.launch(Dispatchers.IO) { - account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl))) + account.relayGroups.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl))) } } @@ -268,7 +268,7 @@ class BuzzDmListViewModel : ViewModel() { val account = account ?: return viewModelScope.launch(Dispatchers.IO) { val me = account.userProfile().pubkeyHex - account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) }) + account.relayGroups.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) }) // The relay's new 30622 normally arrives on the live subscription; refresh anyway so the // row returns even if this screen's socket missed the snapshot. refresh() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt index 248dbc896f..abd18633d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzNewDmViewModel.kt @@ -194,7 +194,7 @@ class BuzzNewDmViewModel : ViewModel() { _status.value = Status.Sending viewModelScope.launch(Dispatchers.IO) { try { - val channelId = account.openBuzzDm(relay, others) + val channelId = account.relayGroups.openBuzzDm(relay, others) val groupId = channelId?.let { GroupId(it, relay) } withContext(Dispatchers.Main) { onOpened(groupId) } } catch (e: CancellationException) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt index d634500dd6..62ead90720 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/JobBoardViewModel.kt @@ -112,19 +112,19 @@ class JobBoardViewModel : ViewModel() { fun file(request: String) = act { account, relay, channelId -> - account.fileBuzzJob(relay, channelId, request) + account.relayGroups.fileBuzzJob(relay, channelId, request) } fun upvote( jobId: String, jobAuthor: String?, ) = act { account, relay, channelId -> - account.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) + account.relayGroups.upvoteBuzzJob(relay, channelId, jobId, jobAuthor) } fun cancel(jobId: String) = act { account, relay, channelId -> - account.cancelBuzzJob(relay, channelId, jobId) + account.relayGroups.cancelBuzzJob(relay, channelId, jobId) } private inline fun act(crossinline block: suspend (Account, NormalizedRelayUrl, String) -> Unit) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt index 31b3145ee9..4dc32ff52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/WorkflowRunBoardViewModel.kt @@ -199,21 +199,21 @@ class WorkflowRunBoardViewModel : ViewModel() { task: String, onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, channelId -> - account.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null + account.relayGroups.triggerBuzzWorkflow(relay, channelId, workflowId, task) != null } fun approve( runId: HexKey, onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, _ -> - account.approveBuzzWorkflowRun(relay, runId) != null + account.relayGroups.approveBuzzWorkflowRun(relay, runId) != null } fun deny( runId: HexKey, onResult: (Boolean) -> Unit, ) = act(onResult) { account, relay, _ -> - account.denyBuzzWorkflowRun(relay, runId) != null + account.relayGroups.denyBuzzWorkflowRun(relay, runId) != null } /** @@ -234,7 +234,7 @@ class WorkflowRunBoardViewModel : ViewModel() { return } viewModelScope.launch(Dispatchers.IO) { - val newId = account.publishBuzzWorkflowDef(relay, channelId, name, yaml) + val newId = account.relayGroups.publishBuzzWorkflowDef(relay, channelId, name, yaml) withContext(Dispatchers.Main) { onResult(newId) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index f4519f4bcb..ae5275e8f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -254,7 +254,7 @@ class RelayGroupMetadataViewModel : ViewModel() { val geohashes = parseGeohashes() val existing = channel if (existing == null) { - account.createRelayGroup( + account.relayGroups.createRelayGroup( relay = relay!!, groupId = groupId, name = name, @@ -270,7 +270,7 @@ class RelayGroupMetadataViewModel : ViewModel() { channelType = if (isBuzzRelay) (if (isForum) BUZZ_CHANNEL_TYPE_FORUM else BUZZ_CHANNEL_TYPE_STREAM) else null, ) } else { - account.editRelayGroupMetadata( + account.relayGroups.editRelayGroupMetadata( channel = existing, name = name, about = about, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index a4e1818b7d..a8573a9032 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -567,7 +567,7 @@ open class ChannelNewMessageViewModel : val pk = user.pubkeyHex if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) { try { - accountViewModel.account.putRelayGroupUser(channel, pk, emptyList()) + accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList()) } catch (e: Exception) { if (e is CancellationException) throw e Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}: ${e.message}")