mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
feat(buzz): wire Buzz direct messages end-to-end (app + amy)
A Buzz DM is a relay-authoritative NIP-29 group whose h/id is a relay-generated UUID, so its timeline reuses the whole relay-group chat stack unchanged. This adds the missing discovery + product layer: - commons: BuzzDmRegistry — process-wide registry fed by LocalCache from the relay-signed DmCreatedEvent (41001) and per-viewer DmVisibilityEvent (30622); tracks conversations (channel id -> participants/relay) and the viewer's hidden set. Unit-tested. - LocalCache: record 41001/30622 into the registry on consume (was store-only). - Account: openBuzzDm (41010), hideBuzzDm (41012), addBuzzDmMember (41011). The relay assigns the channel UUID and confirms via 41001 — we never mint it. - Android: BuzzDmListViewModel (two-phase fetch: discover 41001/30622 #p=me, then fetch each DM's 39000-39003 roster so the shared composer's member gate passes), BuzzDmListScreen (inbox), BuzzNewDmScreen (publish 41010, await the 41001, jump into the shared RelayGroupChatScreen). Reached from a Direct Messages card on the Workspaces tab. - CLI: amy buzz dm list/open/hide/add-member, mirroring buzz-cli. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
@@ -27,10 +27,15 @@ 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.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.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.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
@@ -55,6 +60,11 @@ object BuzzCommands {
|
||||
| [--timeout SECS]
|
||||
|amy buzz personas [--relays R,R] list my kind-30175 personas
|
||||
| [--timeout SECS]
|
||||
|amy buzz dm list [--relays R,R] list my DMs (kind-41001, #p = me)
|
||||
| [--limit N] [--timeout SECS]
|
||||
|amy buzz dm open RELAY PUBKEY [PUBKEY…] open a DM with 1-8 people (kind-41010)
|
||||
|amy buzz dm hide RELAY CHANNEL hide a DM from my sidebar (kind-41012)
|
||||
|amy buzz dm add-member RELAY CHANNEL PUBKEY add a member to a group DM (kind-41011)
|
||||
""".trimMargin()
|
||||
|
||||
suspend fun dispatch(
|
||||
@@ -71,9 +81,139 @@ object BuzzCommands {
|
||||
"attest" to { rest -> attest(dataDir, rest) },
|
||||
"console" to { rest -> console(dataDir, rest) },
|
||||
"personas" to { rest -> personas(dataDir, rest) },
|
||||
"dm" to { rest -> dm(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
/** `buzz dm …` — the Buzz direct-message sub-verbs (list / open / hide / add-member). */
|
||||
private suspend fun dm(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
val usage =
|
||||
"""
|
||||
|amy buzz dm list [--relays R,R] [--limit N] [--timeout SECS]
|
||||
|amy buzz dm open RELAY PUBKEY [PUBKEY…]
|
||||
|amy buzz dm hide RELAY CHANNEL
|
||||
|amy buzz dm add-member RELAY CHANNEL PUBKEY
|
||||
""".trimMargin()
|
||||
return route(
|
||||
"buzz dm",
|
||||
tail,
|
||||
usage,
|
||||
mapOf(
|
||||
"list" to { rest -> dmList(dataDir, rest) },
|
||||
"open" to { rest -> dmOpen(dataDir, rest) },
|
||||
"hide" to { rest -> dmHide(dataDir, rest) },
|
||||
"add-member" to { rest -> dmAddMember(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** `buzz dm list` → drains the relay-signed kind-41001 confirmations addressed to me. */
|
||||
private suspend fun dmList(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relaysFlag = args.flag("relays")
|
||||
val limit = args.flag("limit")?.toIntOrNull() ?: 50
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
|
||||
args.rejectUnknown("relays", "limit", "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(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(me)), limit = limit)
|
||||
val dms =
|
||||
ctx
|
||||
.drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<DmCreatedEvent>()
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.createdAt }
|
||||
.take(limit)
|
||||
.map {
|
||||
mapOf(
|
||||
"dm_id" to it.dmId(),
|
||||
"participants" to it.participants(),
|
||||
"created_at" to it.createdAt,
|
||||
)
|
||||
}
|
||||
Output.emit(mapOf("count" to dms.size, "dms" to dms))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `buzz dm open RELAY PUBKEY [PUBKEY…]` → publishes a kind-41010 with 1-8 `p` participants. */
|
||||
private suspend fun dmOpen(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "buzz dm open RELAY PUBKEY [PUBKEY…]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
val participants = mutableListOf<HexKey>()
|
||||
var i = 1
|
||||
while (true) {
|
||||
val raw = args.positionalOrNull(i) ?: break
|
||||
val hex =
|
||||
decodePublicKeyAsHexOrNull(raw.trim())?.takeIf { it.isValid() }
|
||||
?: return Output.error("bad_args", "invalid public key (npub or 64-char hex): $raw")
|
||||
if (hex !in participants) participants.add(hex)
|
||||
i++
|
||||
}
|
||||
if (participants.size !in DmOpenEvent.MIN_PARTICIPANTS..DmOpenEvent.MAX_PARTICIPANTS) {
|
||||
return Output.error("bad_args", "a DM needs ${DmOpenEvent.MIN_PARTICIPANTS}-${DmOpenEvent.MAX_PARTICIPANTS} participants")
|
||||
}
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val signed = ctx.signer.sign(DmOpenEvent.build(participants))
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"relay" to relay.url,
|
||||
"participants" to participants,
|
||||
"published" to ack.values.any { it.accepted },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `buzz dm hide RELAY CHANNEL` → publishes a kind-41012 scoped by the DM's `h` tag. */
|
||||
private suspend fun dmHide(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int =
|
||||
publishScoped(dataDir, rest, "buzz dm hide RELAY CHANNEL") { _, channelId, _ ->
|
||||
DmHideEvent.build(channelId)
|
||||
}
|
||||
|
||||
/** `buzz dm add-member RELAY CHANNEL PUBKEY` → publishes a kind-41011 (`h` + new `p`). */
|
||||
private suspend fun dmAddMember(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val usage = "buzz dm add-member RELAY CHANNEL PUBKEY"
|
||||
val memberInput = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||
val member =
|
||||
decodePublicKeyAsHexOrNull(memberInput.trim())?.takeIf { it.isValid() }
|
||||
?: return Output.error("bad_args", "invalid public key (npub or 64-char hex): $memberInput")
|
||||
return publishScoped(dataDir, rest, usage) { _, channelId, _ ->
|
||||
DmAddMemberEvent.build(channelId, member)
|
||||
}
|
||||
}
|
||||
|
||||
/** `buzz post RELAY GID <text>` → publishes a kind-40002 stream message with an `h` tag. */
|
||||
private suspend fun post(
|
||||
dataDir: DataDir,
|
||||
|
||||
Reference in New Issue
Block a user