From 257756438d80f3900daa68eb78076cb6c22ac7b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:04:20 +0000 Subject: [PATCH 01/21] feat(commons): extract follow/unfollow verbs into shared actions package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce commons/.../actions/FollowActions as the canonical, non-UI entry point for NIP-02 kind:3 mutations. Accepts pubkeys as HexKey rather than the Compose-bound User model, so callers without a cache (amy CLI, future Android App Functions adapter for Gemini, automation scripts) can drive follow/unfollow directly. Kind3FollowListState.follow/unfollow now delegate to FollowActions, preserving the existing Account.follow(user) signature on Android. Behavior is unchanged for UI callers. Wires up amy follow/unfollow as the first consumer — fetches the freshest kind:3 from outbox relays before mutating so concurrent follows from another client are preserved. --- .../com/vitorpamplona/amethyst/cli/Main.kt | 13 ++ .../amethyst/cli/commands/Commands.kt | 10 ++ .../amethyst/cli/commands/FollowCommand.kt | 160 ++++++++++++++++++ .../amethyst/commons/actions/FollowActions.kt | 109 ++++++++++++ .../nip02FollowList/Kind3FollowListState.kt | 65 +++---- .../commons/actions/FollowActionsTest.kt | 143 ++++++++++++++++ 6 files changed, 455 insertions(+), 45 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActionsTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index db8818f3d7..39e450b567 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -159,6 +159,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.store(dataDir, tail) } + "follow" -> { + Commands.follow(dataDir, tail) + } + + "unfollow" -> { + Commands.unfollow(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -327,6 +335,11 @@ private fun printUsage() { | [--since TS] [--until TS] | [--timeout SECS] | + |Contacts (NIP-02 kind:3): + | follow USER [--timeout SECS] add USER to your contact list + | unfollow USER [--timeout SECS] remove USER from your contact list + | (USER: npub|nprofile|hex|name@domain) + | |Direct messages (NIP-17): | dm send RECIPIENT TEXT send a gift-wrapped DM | [--allow-fallback] (default: only deliver to recipient's kind:10050) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 83a111bd24..ee9d00e6e4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -95,4 +95,14 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = StoreCommands.dispatch(dataDir, tail) + + suspend fun follow( + dataDir: DataDir, + tail: Array, + ): Int = FollowCommand.follow(dataDir, tail) + + suspend fun unfollow( + dataDir: DataDir, + tail: Array, + ): Int = FollowCommand.unfollow(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt new file mode 100644 index 0000000000..5de60d2097 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt @@ -0,0 +1,160 @@ +/* + * 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.actions.FollowActions +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent + +/** + * `amy follow ` and `amy unfollow ` — update the active + * account's NIP-02 kind:3 contact list. + * + * Both commands fetch the user's latest kind:3 from their outbox relays + * before mutating, so concurrent follows from another client are preserved + * (the new event is built on top of the freshest known list). + * + * Identifier formats accepted by ``: npub / nprofile / 64-hex / + * `name@domain.tld` — same set [Context.requireUserHex] handles. + */ +object FollowCommand { + suspend fun follow( + dataDir: DataDir, + rest: Array, + ): Int = run(dataDir, rest, FollowOp.FOLLOW) + + suspend fun unfollow( + dataDir: DataDir, + rest: Array, + ): Int = run(dataDir, rest, FollowOp.UNFOLLOW) + + private enum class FollowOp { FOLLOW, UNFOLLOW } + + private suspend fun run( + dataDir: DataDir, + rest: Array, + op: FollowOp, + ): Int { + if (rest.isEmpty()) { + val verb = if (op == FollowOp.FOLLOW) "follow" else "unfollow" + return Output.error("bad_args", "$verb [--timeout SECS]") + } + val userArg = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val timeoutSecs = args.longFlag("timeout", 8L) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val target = ctx.requireUserHex(userArg) + val self = ctx.identity.pubKeyHex + if (target == self) { + return Output.error("bad_args", "cannot follow/unfollow yourself") + } + + val outbox = ctx.outboxRelays() + if (outbox.isEmpty()) { + return Output.error("no_relays", "no outbox relays configured; run `amy relay add` or `amy create`") + } + + val latest = fetchLatestContactList(ctx, self, outbox, timeoutSecs * 1000) + val previouslyFollowed = latest?.isTaggedUser(target) ?: false + + val newEvent: ContactListEvent? = + when (op) { + FollowOp.FOLLOW -> + FollowActions.buildFollow( + signer = ctx.signer, + pubkeyToFollow = target, + currentContactList = latest, + ) + FollowOp.UNFOLLOW -> + FollowActions.buildUnfollow( + signer = ctx.signer, + pubkeyToUnfollow = target, + currentContactList = latest, + ) + } + + // No-op cases: already following / not following. + if (newEvent == null || newEvent.id == latest?.id) { + Output.emit( + mapOf( + "target" to target, + "op" to op.name.lowercase(), + "changed" to false, + "previously_followed" to previouslyFollowed, + "based_on" to latest?.id, + "follow_count" to (latest?.verifiedFollowKeySet()?.size ?: 0), + ), + ) + return 0 + } + + val ack = ctx.publish(newEvent, outbox) + Output.emit( + mapOf( + "target" to target, + "op" to op.name.lowercase(), + "changed" to true, + "previously_followed" to previouslyFollowed, + "event_id" to newEvent.id, + "created_at" to newEvent.createdAt, + "based_on" to latest?.id, + "follow_count" to newEvent.verifiedFollowKeySet().size, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + /** + * Fetch the freshest kind:3 for [pubKey] from [relays]. Returns null when + * no relay surfaces one within the timeout. We never trust the local + * store alone for the base event — a stale read here would silently drop + * follows the user made from another client. + */ + private suspend fun fetchLatestContactList( + ctx: Context, + pubKey: HexKey, + relays: Set, + timeoutMs: Long, + ): ContactListEvent? { + if (relays.isEmpty()) return null + val filter = Filter(kinds = listOf(ContactListEvent.KIND), authors = listOf(pubKey), limit = 1) + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + return received + .mapNotNull { (_, ev) -> ev as? ContactListEvent } + .filter { it.pubKey == pubKey } + .maxByOrNull { it.createdAt } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt new file mode 100644 index 0000000000..6b8c501a8e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt @@ -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.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag + +/** + * Pure event-building "verbs" for the NIP-02 kind:3 contact list. + * + * Builds a signed [ContactListEvent] but does NOT publish it — callers are + * responsible for relay delivery (e.g. `Account.sendMyPublicAndPrivateOutbox` + * on Android, `Context.publish` in amy). + * + * Canonical entry point for non-UI callers (CLI commands, Android App + * Functions adapters, automation scripts): takes pubkeys as [HexKey] rather + * than the UI-model `User`, so it has no cache or scope dependency and is + * trivially testable. + */ +object FollowActions { + /** + * Build a kind:3 contact list update that follows [pubkeyToFollow]. + * + * If [currentContactList] is non-null, the new event is derived from it + * (preserving the existing follow set and content). If it is null, a fresh + * kind:3 is created containing only this pubkey. + * + * Returns the (already signed) event ready to be published to outbox + * relays. When the user already follows [pubkeyToFollow] the underlying + * builder returns [currentContactList] unchanged — callers that want to + * detect "no-op" can compare event ids. + */ + suspend fun buildFollow( + signer: NostrSigner, + pubkeyToFollow: HexKey, + currentContactList: ContactListEvent?, + relayHint: NormalizedRelayUrl? = null, + ): ContactListEvent = + if (currentContactList != null) { + ContactListEvent.followUser(currentContactList, pubkeyToFollow, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(pubkeyToFollow, relayHint, null)), + relayUse = emptyMap(), + signer = signer, + ) + } + + /** + * Batch-follow variant — adds every pubkey in [pubkeysWithHints] to the + * follow set in a single kind:3 update. Pubkeys already present in + * [currentContactList] are skipped by the underlying builder. + */ + suspend fun buildFollowBatch( + signer: NostrSigner, + pubkeysWithHints: List>, + currentContactList: ContactListEvent?, + ): ContactListEvent { + val contacts = pubkeysWithHints.map { (pk, hint) -> ContactTag(pk, hint, null) } + return if (currentContactList != null) { + ContactListEvent.followUsers(currentContactList, contacts, signer) + } else { + ContactListEvent.createFromScratch( + followUsers = contacts, + relayUse = emptyMap(), + signer = signer, + ) + } + } + + /** + * Build a kind:3 contact list update that removes [pubkeyToUnfollow]. + * + * Returns `null` when [currentContactList] is `null` or has no tags — + * there is nothing to unfollow, and callers should treat this as a no-op + * rather than publishing an empty replacement event. + */ + suspend fun buildUnfollow( + signer: NostrSigner, + pubkeyToUnfollow: HexKey, + currentContactList: ContactListEvent?, + ): ContactListEvent? = + if (currentContactList != null && currentContactList.tags.isNotEmpty()) { + ContactListEvent.unfollowUser(currentContactList, pubkeyToUnfollow, signer) + } else { + null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt index 509e2e1c3e..ec68c611c8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt @@ -21,13 +21,13 @@ package com.vitorpamplona.amethyst.commons.model.nip02FollowList import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.actions.FollowActions import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -112,52 +112,27 @@ class Kind3FollowListState( ) } - suspend fun follow(users: List): ContactListEvent { - val contactList = getFollowListEvent() + suspend fun follow(users: List): ContactListEvent = + FollowActions.buildFollowBatch( + signer = signer, + pubkeysWithHints = users.map { it.pubkeyHex to it.bestRelayHint() }, + currentContactList = getFollowListEvent(), + ) - val contacts = - users.map { - ContactTag(it.pubkeyHex, it.bestRelayHint(), null) - } + suspend fun follow(user: User): ContactListEvent = + FollowActions.buildFollow( + signer = signer, + pubkeyToFollow = user.pubkeyHex, + currentContactList = getFollowListEvent(), + relayHint = user.bestRelayHint(), + ) - return if (contactList != null) { - ContactListEvent.followUsers(contactList, contacts, signer) - } else { - ContactListEvent.createFromScratch( - followUsers = contacts, - relayUse = emptyMap(), - signer = signer, - ) - } - } - - suspend fun follow(user: User): ContactListEvent { - val contactList = getFollowListEvent() - - return if (contactList != null) { - ContactListEvent.followUser(contactList, user.pubkeyHex, signer) - } else { - ContactListEvent.createFromScratch( - followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), - relayUse = emptyMap(), - signer = signer, - ) - } - } - - suspend fun unfollow(user: User): ContactListEvent? { - val contactList = getFollowListEvent() - - return if (contactList != null && contactList.tags.isNotEmpty()) { - ContactListEvent.unfollowUser( - contactList, - user.pubkeyHex, - signer, - ) - } else { - null - } - } + suspend fun unfollow(user: User): ContactListEvent? = + FollowActions.buildUnfollow( + signer = signer, + pubkeyToUnfollow = user.pubkeyHex, + currentContactList = getFollowListEvent(), + ) init { settings.backupContactList?.let { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActionsTest.kt new file mode 100644 index 0000000000..144900c27d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActionsTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class FollowActionsTest { + private val myPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val mySigner = NostrSignerInternal(KeyPair(myPriv.hexToByteArray())) + + // Pre-computed 32-byte (x-only pubkey) hexes — content doesn't matter, only + // length + uniqueness. NIP-02 verification is lenient about these being + // real curve points. + private val alice = "1111111111111111111111111111111111111111111111111111111111111111" + private val bob = "2222222222222222222222222222222222222222222222222222222222222222" + private val carol = "3333333333333333333333333333333333333333333333333333333333333333" + + @Test + fun followFromScratch_createsKind3WithSinglePubkey() = + runTest { + val event = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + assertEquals(ContactListEvent.KIND, event.kind) + assertEquals(mySigner.pubKey, event.pubKey) + assertEquals(setOf(alice), event.verifiedFollowKeySet()) + } + + @Test + fun followFromExistingList_appendsWithoutLosingPriorFollows() = + runTest { + val initial = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + val updated = FollowActions.buildFollow(mySigner, bob, currentContactList = initial) + + assertEquals(setOf(alice, bob), updated.verifiedFollowKeySet()) + // New event must replace the old one (different id), not no-op back. + assertTrue(updated.id != initial.id) + } + + @Test + fun followAlreadyFollowed_isNoOp() = + runTest { + val initial = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + val redundant = FollowActions.buildFollow(mySigner, alice, currentContactList = initial) + + // Underlying builder short-circuits to the same event. + assertSame(initial, redundant) + } + + @Test + fun unfollowFromExistingList_removesOnlyTargetTag() = + runTest { + val twoFollows = + FollowActions.buildFollowBatch( + signer = mySigner, + pubkeysWithHints = listOf(alice to null, bob to null), + currentContactList = null, + ) + assertEquals(setOf(alice, bob), twoFollows.verifiedFollowKeySet()) + + val removed = FollowActions.buildUnfollow(mySigner, alice, currentContactList = twoFollows) + + assertNotNull(removed) + assertEquals(setOf(bob), removed.verifiedFollowKeySet()) + } + + @Test + fun unfollowWithNullCurrent_returnsNull() = + runTest { + val result = FollowActions.buildUnfollow(mySigner, alice, currentContactList = null) + assertNull(result, "no prior list means nothing to unfollow — caller should treat as no-op") + } + + @Test + fun unfollowNonMember_returnsEventWithSameId() = + runTest { + val onlyAlice = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + // Builder short-circuits when the pubkey isn't tagged — we get the + // same event back, so callers can detect no-op by id equality. + val result = FollowActions.buildUnfollow(mySigner, bob, currentContactList = onlyAlice) + + assertNotNull(result) + assertEquals(onlyAlice.id, result.id) + } + + @Test + fun followBatchFromScratch_createsKind3WithAllPubkeys() = + runTest { + val event = + FollowActions.buildFollowBatch( + signer = mySigner, + pubkeysWithHints = listOf(alice to null, bob to null, carol to null), + currentContactList = null, + ) + + assertEquals(setOf(alice, bob, carol), event.verifiedFollowKeySet()) + } + + @Test + fun followBatchOnExistingList_unionsWithoutLosingPriorFollows() = + runTest { + val initial = FollowActions.buildFollow(mySigner, alice, currentContactList = null) + + val updated = + FollowActions.buildFollowBatch( + signer = mySigner, + pubkeysWithHints = listOf(bob to null, carol to null), + currentContactList = initial, + ) + + assertEquals(setOf(alice, bob, carol), updated.verifiedFollowKeySet()) + } +} From cde609203c7e2714164972b2a634bddee1783bb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:23:34 +0000 Subject: [PATCH 02/21] feat(commons): add NIP-50 search verbs in shared actions package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce SearchActions alongside FollowActions as the second of the shared "verbs" usable by amy CLI and a future Android App Functions adapter for Gemini. * searchProfilesFilter / searchNotesFilter build the relay-side Filter with the NIP-50 `search` field set; blank queries return null so callers don't issue unconstrained searches that relays would reject anyway. * resolveSearchRelays picks the caller's kind:10007 list when configured (decrypting NIP-44 private entries via the signer) and falls back to DefaultSearchRelayList — the same set the Android UI uses when the user has no list of their own. Wires up amy search user|note as the first consumer. --- .../com/vitorpamplona/amethyst/cli/Main.kt | 13 ++ .../amethyst/cli/commands/Commands.kt | 5 + .../amethyst/cli/commands/SearchCommand.kt | 189 ++++++++++++++++++ .../amethyst/commons/actions/SearchActions.kt | 111 ++++++++++ .../commons/actions/SearchActionsTest.kt | 143 +++++++++++++ 5 files changed, 461 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActionsTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 39e450b567..850c5a5ac2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -167,6 +167,10 @@ private suspend fun dispatch(argv: Array): Int { Commands.unfollow(dataDir, tail) } + "search" -> { + Commands.search(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -340,6 +344,15 @@ private fun printUsage() { | unfollow USER [--timeout SECS] remove USER from your contact list | (USER: npub|nprofile|hex|name@domain) | + |Search (NIP-50): + | search user QUERY [--limit N] search kind:0 profiles + | [--timeout SECS] + | search note QUERY [--limit N] search event content + | [--kinds K[,K…]] (default kind:1; e.g. 1,30023) + | [--timeout SECS] + | uses your kind:10007 search-relay + | list, falls back to Amethyst defaults + | |Direct messages (NIP-17): | dm send RECIPIENT TEXT send a gift-wrapped DM | [--allow-fallback] (default: only deliver to recipient's kind:10050) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index ee9d00e6e4..f5dc36b462 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -105,4 +105,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = FollowCommand.unfollow(dataDir, tail) + + suspend fun search( + dataDir: DataDir, + tail: Array, + ): Int = SearchCommand.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt new file mode 100644 index 0000000000..00b29ff9b7 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt @@ -0,0 +1,189 @@ +/* + * 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.actions.SearchActions +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent + +/** + * `amy search ` — NIP-50 full-text search across the + * caller's configured search relays (kind:10007) or, when none is set, + * Amethyst's curated default search-relay list. + * + * Two subcommands: + * * `search user ` drains kind:0 metadata events whose content + * matches [query] — useful for resolving a partial display name to + * an npub before a follow / DM. + * * `search note ` drains kind:1 short text notes matching + * [query]. Use `--kinds 1,30023` to widen to long-form articles. + * + * Output is the raw relay-side hit set deduped by event id and sorted + * by `created_at` descending. Client-side pseudo-kind filters + * (`reply` / `media`) live in + * [com.vitorpamplona.amethyst.commons.search.SearchResultFilter] and + * are not exposed here yet. + */ +object SearchCommand { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "search [--limit N] [--timeout SECS]") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "user" -> searchUsers(dataDir, rest) + "note" -> searchNotes(dataDir, rest) + else -> Output.error("bad_args", "search ${tail[0]} — expected user|note") + } + } + + private suspend fun searchUsers( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "search user [--limit N] [--timeout SECS]") + val query = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val limit = args.longFlag("limit", 20L).toInt() + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val filter = + SearchActions.searchProfilesFilter(query, limit) + ?: return Output.error("bad_args", "query must not be blank") + + return runSearch(dataDir, query, filter, timeoutMs) { events -> + events + .mapNotNull { it as? MetadataEvent } + .map { ev -> + val parsed = + try { + Output.mapper.readTree(ev.content) + } catch (_: Exception) { + null + } + mapOf( + "event_id" to ev.id, + "pubkey" to ev.pubKey, + "created_at" to ev.createdAt, + "metadata" to (parsed ?: emptyMap()), + ) + } + } + } + + private suspend fun searchNotes( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "search note [--limit N] [--timeout SECS] [--kinds K[,K…]]") + val query = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val limit = args.longFlag("limit", 50L).toInt() + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + val kindList = + args.flags["kinds"] + ?.split(',') + ?.mapNotNull { it.trim().toIntOrNull() } + ?.takeIf { it.isNotEmpty() } + ?: SearchActions.DEFAULT_NOTE_KINDS + + val filter = + SearchActions.searchNotesFilter(query, kinds = kindList, limit = limit) + ?: return Output.error("bad_args", "query must not be blank") + + return runSearch(dataDir, query, filter, timeoutMs) { events -> + events + .filter { it.kind in kindList } + .map { ev -> + mapOf( + "event_id" to ev.id, + "pubkey" to ev.pubKey, + "kind" to ev.kind, + "created_at" to ev.createdAt, + "content" to ev.content, + ) + } + } + } + + private suspend fun runSearch( + dataDir: DataDir, + query: String, + filter: Filter, + timeoutMs: Long, + render: (List) -> List>, + ): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = + SearchActions.resolveSearchRelays( + signer = ctx.signer, + currentList = loadOwnSearchList(ctx), + ) + if (relays.isEmpty()) { + return Output.error("no_relays", "no search relays available (no kind:10007 and DefaultSearchRelayList is empty?)") + } + + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + val deduped = + received + .map { it.second } + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + + Output.emit( + mapOf( + "query" to query, + "queried_relays" to relays.map { it.url }, + "match_count" to deduped.size, + "results" to render(deduped), + ), + ) + return 0 + } finally { + ctx.close() + } + } + + /** + * Pull the caller's own kind:10007 from the local store. Returns null + * when amy has never observed one — caller falls back to + * [com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList] + * via [SearchActions.resolveSearchRelays]. + */ + private suspend fun loadOwnSearchList(ctx: Context): SearchRelayListEvent? = + ctx.store + .query( + Filter( + authors = listOf(ctx.identity.pubKeyHex), + kinds = listOf(SearchRelayListEvent.KIND), + limit = 1, + ), + ).firstOrNull() as? SearchRelayListEvent +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt new file mode 100644 index 0000000000..78f6acbcef --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt @@ -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.commons.actions + +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent + +/** + * Pure NIP-50 search-filter assembly + search-relay resolution. + * + * Like [FollowActions], these are non-UI verbs callable from any context + * (amy CLI, future Android App Functions adapter for Gemini, automation). + * The actions only build [Filter]s and pick relays — caller drives the + * actual subscription / drain via its own relay client. + * + * For client-side post-filtering (dedup, pseudo-kinds like `reply` / `media`) + * see [com.vitorpamplona.amethyst.commons.search.SearchResultFilter]. + */ +object SearchActions { + /** Default kinds for "search notes" — kind:1 short text notes. */ + val DEFAULT_NOTE_KINDS: List = listOf(TextNoteEvent.KIND) + + /** + * Build a NIP-50 filter for searching kind:0 profile metadata. + * + * Returns null for a blank [query] — callers should treat as "no + * results" rather than issuing an unconstrained search that most + * relays would reject anyway. + */ + fun searchProfilesFilter( + query: String, + limit: Int = 20, + ): Filter? { + val q = query.trim() + if (q.isEmpty()) return null + return Filter( + kinds = listOf(MetadataEvent.KIND), + search = q, + limit = limit, + ) + } + + /** + * Build a NIP-50 filter for searching event content. Defaults to + * kind:1 short text notes; pass [kinds] to widen (e.g. include + * kind:30023 long-form or kind:9802 highlights). + */ + fun searchNotesFilter( + query: String, + kinds: List = DEFAULT_NOTE_KINDS, + limit: Int = 50, + since: Long? = null, + until: Long? = null, + ): Filter? { + val q = query.trim() + if (q.isEmpty()) return null + return Filter( + kinds = kinds, + search = q, + limit = limit, + since = since, + until = until, + ) + } + + /** + * Pick the relay set to query for NIP-50 search. + * + * Strategy: when [currentList] (the user's kind:10007 search-relay + * list) is present, use its public + decrypted-private relays. + * Otherwise fall back to [fallback] (defaults to Amethyst's curated + * [DefaultSearchRelayList] — the same set the Android UI uses when + * the user hasn't configured their own). + * + * [signer] is only consulted when [currentList] is non-null and has + * private (NIP-44 encrypted) relay entries; an internal/local signer + * is fine, a NIP-46/NIP-55 signer will cost a round-trip. + */ + suspend fun resolveSearchRelays( + signer: NostrSigner, + currentList: SearchRelayListEvent?, + fallback: Collection = DefaultSearchRelayList, + ): Set { + if (currentList == null) return fallback.toSet() + val combined = currentList.relays(signer) + return if (combined.isEmpty()) fallback.toSet() else combined.toSet() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActionsTest.kt new file mode 100644 index 0000000000..d6ad1c0649 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActionsTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SearchActionsTest { + private val priv = "0000000000000000000000000000000000000000000000000000000000000007" + private val signer = NostrSignerInternal(KeyPair(priv.hexToByteArray())) + + @Test + fun searchProfilesFilter_buildsKind0FilterWithSearchField() { + val filter = SearchActions.searchProfilesFilter("alice", limit = 10) + + assertNotNull(filter) + assertEquals(listOf(MetadataEvent.KIND), filter.kinds) + assertEquals("alice", filter.search) + assertEquals(10, filter.limit) + assertNull(filter.authors, "must not constrain authors — search is global") + } + + @Test + fun searchProfilesFilter_trimsWhitespace() { + val filter = SearchActions.searchProfilesFilter(" alice ") + assertNotNull(filter) + assertEquals("alice", filter.search) + } + + @Test + fun searchProfilesFilter_returnsNullForBlankQuery() { + assertNull(SearchActions.searchProfilesFilter("")) + assertNull(SearchActions.searchProfilesFilter(" ")) + } + + @Test + fun searchNotesFilter_defaultsToKind1() { + val filter = SearchActions.searchNotesFilter("hello") + assertNotNull(filter) + assertEquals(listOf(TextNoteEvent.KIND), filter.kinds) + assertEquals("hello", filter.search) + } + + @Test + fun searchNotesFilter_acceptsCustomKindsAndTimeWindow() { + val filter = + SearchActions.searchNotesFilter( + query = "music", + kinds = listOf(1, 30023), + limit = 100, + since = 1_700_000_000, + until = 1_800_000_000, + ) + assertNotNull(filter) + assertEquals(listOf(1, 30023), filter.kinds) + assertEquals(100, filter.limit) + assertEquals(1_700_000_000, filter.since) + assertEquals(1_800_000_000, filter.until) + } + + @Test + fun searchNotesFilter_returnsNullForBlankQuery() { + assertNull(SearchActions.searchNotesFilter("")) + assertNull(SearchActions.searchNotesFilter("\t\n")) + } + + @Test + fun resolveSearchRelays_fallsBackToDefaultsWhenNoListConfigured() = + runTest { + val relays = SearchActions.resolveSearchRelays(signer, currentList = null) + assertEquals(DefaultSearchRelayList, relays) + } + + @Test + fun resolveSearchRelays_usesConfiguredPublicRelaysWhenAvailable() = + runTest { + val customRelay = RelayUrlNormalizer.normalizeOrNull("wss://search.example.com") + assertNotNull(customRelay) + + val list = SearchRelayListEvent.create(relays = listOf(customRelay), signer = signer) + val relays = SearchActions.resolveSearchRelays(signer, currentList = list) + + assertEquals(setOf(customRelay), relays) + } + + @Test + fun resolveSearchRelays_respectsCustomFallback() = + runTest { + val custom = + listOfNotNull( + RelayUrlNormalizer.normalizeOrNull("wss://only-fallback.example"), + ) + val relays = + SearchActions.resolveSearchRelays( + signer = signer, + currentList = null, + fallback = custom, + ) + assertEquals(custom.toSet(), relays) + } + + @Test + fun resolveSearchRelays_emptyConfiguredListFallsBack() = + runTest { + val emptyList = SearchRelayListEvent.create(relays = emptyList(), signer = signer) + val relays = SearchActions.resolveSearchRelays(signer, currentList = emptyList) + + // An author who published a kind:10007 with no relays is treated + // the same as no list at all — we don't want to query nothing. + assertTrue(relays.isNotEmpty()) + assertEquals(DefaultSearchRelayList, relays) + } +} From 2e47cb71107a5515f047dcf852672abec4dd8616 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:33:15 +0000 Subject: [PATCH 03/21] feat(commons): add NIP-57 zap verbs in shared actions package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third verb extraction alongside FollowActions / SearchActions, scoped to event building so the action stays target-agnostic (commonMain, no JVM/Android coupling). * buildUserZapRequest / buildEventZapRequest wrap the two LnZapRequestEvent.create overloads with a uniform call shape and sensible defaults (PUBLIC zap, no LNURL, no poll). * extractLnAddress pulls lud16 (preferred) or lud06 from a kind:0 metadata event, returning null when neither is set. * satsToMillisats covers the sats→msats conversion that every caller would otherwise duplicate. Wires up amy zap user|event as the first consumer. The Lightning round-trip (LNURL fetch + invoice retrieval) goes through the existing LightningAddressResolver in commons/jvmAndroid; the BOLT11 invoice is printed but not auto-paid since amy has no NWC wallet wired up yet. --- .../com/vitorpamplona/amethyst/cli/Main.kt | 12 + .../amethyst/cli/commands/Commands.kt | 5 + .../amethyst/cli/commands/ZapCommand.kt | 233 ++++++++++++++++++ .../amethyst/commons/actions/ZapActions.kt | 116 +++++++++ .../commons/actions/ZapActionsTest.kt | 209 ++++++++++++++++ 5 files changed, 575 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 850c5a5ac2..6d9c0d8fec 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -171,6 +171,10 @@ private suspend fun dispatch(argv: Array): Int { Commands.search(dataDir, tail) } + "zap" -> { + Commands.zap(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -344,6 +348,14 @@ private fun printUsage() { | unfollow USER [--timeout SECS] remove USER from your contact list | (USER: npub|nprofile|hex|name@domain) | + |Zaps (NIP-57): + | zap user USER SATS build a profile zap-request, fetch a BOLT11 + | [--comment X] [--anon|--private] invoice from the recipient's LN service + | [--timeout SECS] (no auto-payment — paste invoice into a wallet) + | zap event EVENT-ID SATS same, but attribute the zap to a specific + | [--comment X] [--anon|--private] event (must be in local store) + | [--timeout SECS] + | |Search (NIP-50): | search user QUERY [--limit N] search kind:0 profiles | [--timeout SECS] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index f5dc36b462..446b3acf1c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -110,4 +110,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = SearchCommand.dispatch(dataDir, tail) + + suspend fun zap( + dataDir: DataDir, + tail: Array, + ): Int = ZapCommand.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt new file mode 100644 index 0000000000..0e22df61ab --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -0,0 +1,233 @@ +/* + * 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.actions.ZapActions +import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import okhttp3.OkHttpClient + +/** + * `amy zap ` — build a NIP-57 zap request and + * fetch a BOLT11 invoice from the recipient's Lightning service. + * + * Two subcommands: + * * `zap user ` — profile zap (no event reference) + * * `zap event ` — event zap (must be in local store) + * + * The flow is: + * 1. Resolve recipient identifier → pubkey + kind:0 metadata. + * 2. Extract LN address (`lud16` preferred, then `lud06` LNURL). + * 3. Build + sign the NIP-57 kind:9734 zap-request event via + * [ZapActions]. + * 4. POST it to the recipient's LNURL-pay callback via + * [LightningAddressResolver] to receive a BOLT11 invoice. + * + * The invoice is printed but **not** auto-paid — amy has no NWC wallet + * wired up yet. Paste the invoice into any LN wallet to settle. + */ +object ZapCommand { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "zap [--comment X] [--anon] [--timeout SECS]") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "user" -> zapUser(dataDir, rest) + "event" -> zapEvent(dataDir, rest) + else -> Output.error("bad_args", "zap ${tail[0]} — expected user|event") + } + } + + private suspend fun zapUser( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--timeout SECS]") + val userArg = rest[0] + val sats = + rest[1].toLongOrNull()?.takeIf { it > 0 } + ?: return Output.error("bad_args", "sats must be a positive integer (got '${rest[1]}')") + val args = Args(rest.drop(2).toTypedArray()) + val comment = args.flag("comment") ?: "" + val zapType = parseZapType(args) + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val recipient = ctx.requireUserHex(userArg) + val metadata = + fetchLatestMetadata(ctx, recipient, ctx.bootstrapRelays(), timeoutMs) + ?: return Output.error("not_found", "no kind:0 metadata found for $recipient") + val lnAddress = + ZapActions.extractLnAddress(metadata) + ?: return Output.error("no_lightning", "recipient has no lud16 or lud06 in their profile") + + val request = + ZapActions.buildUserZapRequest( + signer = ctx.signer, + recipientPubkey = recipient, + amountMillisats = ZapActions.satsToMillisats(sats), + inboxRelays = ctx.outboxRelays(), + comment = comment, + zapType = zapType, + ) + + emitZapResult(ctx, sats, lnAddress, comment, request, zapType) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun zapEvent( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--timeout SECS]") + val eventId = rest[0] + if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex (nevent bech32 not yet supported)") + val sats = + rest[1].toLongOrNull()?.takeIf { it > 0 } + ?: return Output.error("bad_args", "sats must be a positive integer (got '${rest[1]}')") + val args = Args(rest.drop(2).toTypedArray()) + val comment = args.flag("comment") ?: "" + val zapType = parseZapType(args) + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val zappedEvent = + ctx.store.query(Filter(ids = listOf(eventId), limit = 1)).firstOrNull() + ?: return Output.error("not_found", "event $eventId not in local store; sync first or fetch by id") + + val metadata = + fetchLatestMetadata(ctx, zappedEvent.pubKey, ctx.bootstrapRelays(), timeoutMs) + ?: return Output.error("not_found", "no kind:0 metadata found for author ${zappedEvent.pubKey}") + val lnAddress = + ZapActions.extractLnAddress(metadata) + ?: return Output.error("no_lightning", "event author has no lud16 or lud06 in their profile") + + val request = + ZapActions.buildEventZapRequest( + signer = ctx.signer, + zappedEvent = zappedEvent, + amountMillisats = ZapActions.satsToMillisats(sats), + inboxRelays = ctx.outboxRelays(), + comment = comment, + zapType = zapType, + ) + + emitZapResult(ctx, sats, lnAddress, comment, request, zapType, zappedEventId = zappedEvent.id) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun emitZapResult( + ctx: Context, + sats: Long, + lnAddress: String, + comment: String, + request: LnZapRequestEvent, + zapType: LnZapEvent.ZapType, + zappedEventId: HexKey? = null, + ) { + // Reuse the same OkHttp instance the Context uses for nip-05 / WS; + // this respects any proxy/timeout config wired in there. + val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx)) + + val result = + resolver.fetchInvoice( + lnAddress = lnAddress, + milliSats = ZapActions.satsToMillisats(sats), + message = comment, + zapRequest = request, + ) + + when (result) { + is LightningAddressResolver.Result.Success -> { + Output.emit( + buildMap { + put("ln_address", lnAddress) + put("amount_sats", sats) + put("zap_type", zapType.name.lowercase()) + put("comment", comment) + put("zap_request_id", request.id) + if (zappedEventId != null) put("zapped_event_id", zappedEventId) + put("invoice", result.invoice) + }, + ) + } + is LightningAddressResolver.Result.Error -> { + Output.error("invoice_failed", result.message) + } + } + } + + private fun parseZapType(args: Args): LnZapEvent.ZapType = + when { + args.bool("anon") -> LnZapEvent.ZapType.ANONYMOUS + args.bool("private") -> LnZapEvent.ZapType.PRIVATE + else -> LnZapEvent.ZapType.PUBLIC + } + + private suspend fun fetchLatestMetadata( + ctx: Context, + pubKey: HexKey, + relays: Set, + timeoutMs: Long, + ): MetadataEvent? { + // Cache-first: try the local store before going to the network. + ctx.profileOf(pubKey)?.let { return it } + if (relays.isEmpty()) return null + val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey), limit = 1) + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + return received + .mapNotNull { (_, ev) -> ev as? MetadataEvent } + .filter { it.pubKey == pubKey } + .maxByOrNull { it.createdAt } + } + + /** + * Per-invocation OkHttpClient. Amy's [Context] also has its own OkHttp + * (for WS + NIP-05); we keep this separate because [Context.okhttp] is + * private — exposing it just to reuse here would widen the API more + * than is warranted for a single LNURL fetch. + */ + private fun sharedOkHttp( + @Suppress("UNUSED_PARAMETER") ctx: Context, + ): OkHttpClient = OkHttpClient.Builder().build() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt new file mode 100644 index 0000000000..54136a2e76 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +/** + * NIP-57 zap-request building + LN address extraction. + * + * Returns a signed [LnZapRequestEvent] (kind:9734) — the artifact a caller + * hands to a LNURL-pay callback to receive a BOLT11 invoice. The Lightning + * round-trip (LNURL fetch, invoice retrieval, optional NWC payment) is + * intentionally out of scope here; callers compose this with + * `LightningAddressResolver` (commons/jvmAndroid) or their own LN client. + * + * Pattern matches [FollowActions] and [SearchActions]: shared, pure logic + * usable from amy CLI, the future Android App Functions adapter for Gemini, + * and any other non-UI consumer. + */ +object ZapActions { + /** Convert sats to millisats — LN-side amount unit. */ + fun satsToMillisats(sats: Long): Long = sats * 1000L + + /** + * Extract the LN address (Lightning Address or LNURL) from a kind:0 + * metadata event. Prefers `lud16` (Lightning Address, `user@domain`) + * over `lud06` (raw LNURL). Returns null when the user has no LN + * details published. + */ + fun extractLnAddress(metadata: MetadataEvent): String? = metadata.contactMetaData()?.lnAddress() + + /** + * Build a NIP-57 profile zap request — pays [recipientPubkey] directly, + * not attached to any specific event. + * + * [inboxRelays] becomes the `["relays", ...]` tag of the zap request: + * the LN provider publishes the kind:9735 zap *receipt* to these + * relays. These should be the sender's read-side (NIP-65 inbox) + * relays so the sender's clients see the receipt land. + * + * Pass [lnurl] when known to stamp it as a tag on the request — some + * receipt validators key off it. + */ + suspend fun buildUserZapRequest( + signer: NostrSigner, + recipientPubkey: HexKey, + amountMillisats: Long, + inboxRelays: Set, + comment: String = "", + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + lnurl: String? = null, + ): LnZapRequestEvent = + LnZapRequestEvent.create( + userHex = recipientPubkey, + relays = inboxRelays, + signer = signer, + message = comment, + zapType = zapType, + amountMillisats = amountMillisats, + lnurl = lnurl, + ) + + /** + * Build a NIP-57 event-zap request — pays the author of + * [zappedEvent] in the context of that specific event. Override + * [toUserPubkey] when the payment should go to a co-author or + * delegated recipient (zap splits); when null the zap targets + * `zappedEvent.pubKey`. + */ + suspend fun buildEventZapRequest( + signer: NostrSigner, + zappedEvent: Event, + amountMillisats: Long, + inboxRelays: Set, + comment: String = "", + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + toUserPubkey: HexKey? = null, + pollOption: Int? = null, + lnurl: String? = null, + ): LnZapRequestEvent = + LnZapRequestEvent.create( + zappedEvent = zappedEvent, + relays = inboxRelays, + signer = signer, + pollOption = pollOption, + message = comment, + zapType = zapType, + toUserPubHex = toUserPubkey, + amountMillisats = amountMillisats, + lnurl = lnurl, + ) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt new file mode 100644 index 0000000000..acb37eda87 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ZapActionsTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val authorPriv = "000000000000000000000000000000000000000000000000000000000000000d" + private val recipientPriv = "0000000000000000000000000000000000000000000000000000000000000011" + private val signer = NostrSignerInternal(KeyPair(senderPriv.hexToByteArray())) + private val authorSigner = NostrSignerInternal(KeyPair(authorPriv.hexToByteArray())) + + // Use a real curve-point pubkey — PRIVATE / ANONYMOUS zaps internally do + // NIP-04-style ECDH with the recipient, which rejects garbage pubkeys. + private val recipientPubkey = xOnly(recipientPriv) + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://inbox.example")!! + + private fun xOnly(privHex: String) = + Secp256k1Instance + .compressedPubKeyFor(privHex.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + @Test + fun satsToMillisats_multipliesByThousand() { + assertEquals(0L, ZapActions.satsToMillisats(0)) + assertEquals(1_000L, ZapActions.satsToMillisats(1)) + assertEquals(21_000_000L, ZapActions.satsToMillisats(21_000)) + } + + @Test + fun extractLnAddress_prefersLud16OverLud06() = + runTest { + val metadata = + signer.sign( + MetadataEvent.createNew( + name = "alice", + lnAddress = "alice@walletofsatoshi.com", + lnURL = "lnurl1somelongstring", + ), + ) + assertEquals("alice@walletofsatoshi.com", ZapActions.extractLnAddress(metadata)) + } + + @Test + fun extractLnAddress_fallsBackToLud06WhenNoLud16() = + runTest { + val metadata = + signer.sign( + MetadataEvent.createNew( + name = "bob", + lnURL = "lnurl1bobsLightning", + ), + ) + assertEquals("lnurl1bobsLightning", ZapActions.extractLnAddress(metadata)) + } + + @Test + fun extractLnAddress_returnsNullWhenNoLnDetails() = + runTest { + val metadata = + signer.sign( + MetadataEvent.createNew(name = "noln"), + ) + assertNull(ZapActions.extractLnAddress(metadata)) + } + + @Test + fun buildUserZapRequest_publicTypeStampsAllFields() = + runTest { + val request = + ZapActions.buildUserZapRequest( + signer = signer, + recipientPubkey = recipientPubkey, + amountMillisats = 21_000L, + inboxRelays = setOf(relay), + comment = "thanks!", + zapType = LnZapEvent.ZapType.PUBLIC, + lnurl = "lnurl1example", + ) + + assertEquals(9734, request.kind) + assertEquals(signer.pubKey, request.pubKey, "PUBLIC zap is signed by the sender") + assertEquals("thanks!", request.content) + + val tagMap = request.tags.groupBy { it[0] } + assertEquals(recipientPubkey, tagMap["p"]?.first()?.get(1)) + assertEquals("21000", tagMap["amount"]?.first()?.get(1)) + assertEquals("lnurl1example", tagMap["lnurl"]?.first()?.get(1)) + assertTrue(tagMap["relays"]?.first()?.contains(relay.url) == true) + assertNull(tagMap["anon"], "PUBLIC zap must not carry an anon tag") + } + + @Test + fun buildUserZapRequest_anonymousTypeUsesEphemeralKeyAndAnonTag() = + runTest { + val request = + ZapActions.buildUserZapRequest( + signer = signer, + recipientPubkey = recipientPubkey, + amountMillisats = 1_000L, + inboxRelays = setOf(relay), + zapType = LnZapEvent.ZapType.ANONYMOUS, + ) + + assertTrue( + request.pubKey != signer.pubKey, + "ANONYMOUS zaps are signed with a freshly-generated keypair, not the sender's", + ) + assertNotNull(request.tags.firstOrNull { it[0] == "anon" }) + } + + @Test + fun buildUserZapRequest_privateTypeCarriesAnonTagWithEncryptedPayload() = + runTest { + val request = + ZapActions.buildUserZapRequest( + signer = signer, + recipientPubkey = recipientPubkey, + amountMillisats = 1_000L, + inboxRelays = setOf(relay), + zapType = LnZapEvent.ZapType.PRIVATE, + ) + + // NIP-57 PRIVATE zaps use an ephemeral key derived from + // (sender, recipient, zappedEvent) so the recipient can re-derive + // and verify origin via NIP-04 decryption of the anon tag value. + // The outer event is therefore NOT signed by the sender. + val anon = request.tags.firstOrNull { it[0] == "anon" } + assertNotNull(anon, "PRIVATE zap must carry an anon tag") + assertTrue( + (anon.getOrNull(1) ?: "").isNotEmpty(), + "PRIVATE zap's anon tag carries the NIP-04-encrypted private payload", + ) + } + + @Test + fun buildEventZapRequest_carriesEventTagAndAuthorPTag() = + runTest { + val note = authorSigner.sign(TextNoteEvent.build("hello world")) + + val request = + ZapActions.buildEventZapRequest( + signer = signer, + zappedEvent = note, + amountMillisats = 5_000L, + inboxRelays = setOf(relay), + comment = "great post", + ) + + val tagMap = request.tags.groupBy { it[0] } + assertEquals(note.id, tagMap["e"]?.first()?.get(1)) + assertEquals(note.pubKey, tagMap["p"]?.first()?.get(1)) + assertEquals("5000", tagMap["amount"]?.first()?.get(1)) + assertEquals("great post", request.content) + } + + @Test + fun buildEventZapRequest_toUserPubkeyOverridesAuthorTag() = + runTest { + val note = authorSigner.sign(TextNoteEvent.build("split me")) + val splitTo = "2222222222222222222222222222222222222222222222222222222222222222" + + val request = + ZapActions.buildEventZapRequest( + signer = signer, + zappedEvent = note, + amountMillisats = 5_000L, + inboxRelays = setOf(relay), + toUserPubkey = splitTo, + ) + + val pTag = request.tags.firstOrNull { it[0] == "p" } + assertEquals(splitTo, pTag?.getOrNull(1), "explicit toUserPubkey wins over event.pubKey") + } +} From 17cee60aac54d4250910c34adb471eac7968a64d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 18:23:59 +0000 Subject: [PATCH 04/21] feat(amethyst): expose searchProfiles to Gemini via androidx.appfunctions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Phase 2 verb wired through to the Android App Functions runtime so Gemini (and other system agents) can drive Amethyst. Scope is intentionally narrow: * One read-only verb (searchProfiles), built on top of the existing SearchActions in commons. No write verbs yet — they need a story for NIP-46 / NIP-55 signer prompts from a background dispatcher. * Play channel only. appfunctions 1.0.0-alpha09 is a Google AI alpha; F-Droid builds continue to ship without any Google AI dependencies. Architecture: * AmethystAppFunctions — plain Kotlin host with @AppFunction methods. The KSP-driven appfunctions-compiler discovers them and generates the dispatch metadata XML at build time. * PlayAmethyst — play-only Application subclass implementing AppFunctionConfiguration.Provider; supplies the factory the library uses to construct the host class. Manifest replaces android:name in the play flavor only; F-Droid keeps the unmodified Amethyst class. * The androidx-provided PlatformAppFunctionService is registered in the play manifest as the bind point — Amethyst doesn't ship a custom Service. KSP is now a project-wide plugin (apply false at the root); applied in amethyst/ to run the appfunctions-compiler over the play sourceSet. Amethyst becomes `open class` so PlayAmethyst can extend it. No other behavior change. --- amethyst/build.gradle.kts | 10 + .../com/vitorpamplona/amethyst/Amethyst.kt | 2 +- amethyst/src/play/AndroidManifest.xml | 25 +- .../vitorpamplona/amethyst/PlayAmethyst.kt | 52 ++++ .../appfunctions/AmethystAppFunctions.kt | 228 ++++++++++++++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 10 + 7 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/PlayAmethyst.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 7bf0cda845..be6e7894bc 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.googleServices) alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.serialization) + alias(libs.plugins.googleKsp) } fun getCurrentBranch(): String = @@ -413,6 +414,15 @@ dependencies { // on de-Googled / GrapheneOS devices that ship the F-Droid build. "playImplementation"(libs.play.services.cast.framework) + // androidx.appfunctions — Gemini App Functions adapter. Pre-stable + // (alpha) as of May 2026 — scoped to the play channel so the F-Droid + // build stays free of Google AI dependencies. Surface is an + // AppFunctionService registered in amethyst/src/play/AndroidManifest.xml, + // generated at compile time by the KSP-driven appfunctions-compiler. + "playImplementation"(libs.androidx.appfunctions) + "playImplementation"(libs.androidx.appfunctions.service) + "kspPlay"(libs.androidx.appfunctions.compiler) + // Charts implementation(libs.vico.charts.compose) implementation(libs.vico.charts.m3) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 4ac9fefc0e..3ae80f681f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel -class Amethyst : Application() { +open class Amethyst : Application() { init { Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR Log.d("AmethystApp") { "Creating App $this" } diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index aafa18bfb6..23fc8df4b3 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -2,8 +2,13 @@ + + android:name=".PlayAmethyst" + tools:replace="android:name"> + + + + + + + \ No newline at end of file diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/PlayAmethyst.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/PlayAmethyst.kt new file mode 100644 index 0000000000..89f4416751 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/PlayAmethyst.kt @@ -0,0 +1,52 @@ +/* + * 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 + +import androidx.appfunctions.service.AppFunctionConfiguration +import com.vitorpamplona.amethyst.appfunctions.AmethystAppFunctions + +/** + * Play-flavor Application subclass that adds the + * [AppFunctionConfiguration.Provider] surface required by + * androidx.appfunctions. + * + * Lives in the `play` source set only because [AppFunctionConfiguration] + * (and the entire appfunctions library) is a play-channel `playImplementation` + * dependency — the F-Droid build does not ship Google AI libraries and + * continues to use the unmodified [Amethyst] Application class. + * + * Registered via `tools:replace="android:name"` in + * `amethyst/src/play/AndroidManifest.xml`. + */ +class PlayAmethyst : + Amethyst(), + AppFunctionConfiguration.Provider { + override val appFunctionConfiguration: AppFunctionConfiguration + // Lazy single instance — AmethystAppFunctions is stateless (it + // reaches into Amethyst.instance on every call), but the runtime + // is free to invoke functions concurrently, so we hand back the + // same object for every dispatch rather than rebuilding. + get() = + AppFunctionConfiguration + .Builder() + .addEnclosingClassFactory(AmethystAppFunctions::class.java) { AmethystAppFunctions() } + .build() +} diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt new file mode 100644 index 0000000000..2e67499e73 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -0,0 +1,228 @@ +/* + * 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.appfunctions + +import androidx.appfunctions.AppFunctionContext +import androidx.appfunctions.AppFunctionSerializable +import androidx.appfunctions.service.AppFunction +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.actions.SearchActions +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Bridge that exposes Amethyst's "verbs" (commons/.../actions/) to the Android + * App Functions runtime, which Gemini and other system agents can drive. + * + * **Status — pre-stable.** Built against androidx.appfunctions 1.0.0-alpha09. + * The API is still moving; treat every release as ABI-breaking until 1.0.0 + * ships. Scoped to the `play` build flavor only — the F-Droid channel + * ships without any Google AI dependencies. + * + * Plain class, no inheritance — the KSP compiler discovers `@AppFunction` + * methods and generates the dispatcher glue. Construction is wired in + * [PlayAmethyst.appFunctionConfiguration]. + * + * Only read-only verbs are exposed so far. Write verbs (post, follow, zap) + * are intentionally deferred until we resolve the signer-prompt flow for + * NIP-46 / NIP-55 signers, which cannot interact with the user from a + * background AppFunctionService invocation. + * + * Account scoping uses the currently active account from + * [com.vitorpamplona.amethyst.Amethyst.instance.sessionManager] — the same + * Account the foreground UI is bound to. When no account is signed in, + * every function returns an empty result rather than failing the call. + */ +class AmethystAppFunctions { + /** + * Searches for Nostr user profiles matching [query] via NIP-50 full-text + * search across the active account's configured search relays + * (kind:10007), falling back to Amethyst's curated default search-relay + * set when none is configured. + * + * @param query free-form search text (display name, NIP-05 handle, etc.) + * @param limit max number of profiles to return — capped to 50. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchProfiles( + appFunctionContext: AppFunctionContext, + query: String, + limit: Int = 10, + ): SearchProfilesResult { + val cappedLimit = limit.coerceIn(1, 50) + val filter = SearchActions.searchProfilesFilter(query, cappedLimit) ?: return SearchProfilesResult.empty() + + val app = Amethyst.instance + val account = app.sessionManager.loggedInAccount() ?: return SearchProfilesResult.empty() + + // SearchRelayListState's flow already resolves to a concrete relay + // set: NIP-44-decrypted private entries + public entries, or the + // curated default set when the user has no kind:10007. Same source + // of truth the foreground UI uses. + val relays = account.searchRelayList.flow.value + if (relays.isEmpty()) return SearchProfilesResult.empty() + + val events = drain(app, relays, filter, GEMINI_DRAIN_TIMEOUT_MS) + + val hits = + events + .mapNotNull { it as? MetadataEvent } + .distinctBy { it.pubKey } + .sortedByDescending { it.createdAt } + .take(cappedLimit) + .map { it.toProfileHit() } + + return SearchProfilesResult(matches = hits) + } + + /** + * One-shot relay drain: subscribe with [filter] against [relays], collect + * events until every relay sends EOSE or [timeoutMs] elapses, then + * unsubscribe. Mirrors `Context.drain` in amy — kept inline here because + * Account exposes a live `INostrClient` rather than a drain helper. + */ + private suspend fun drain( + app: com.vitorpamplona.amethyst.AppModules, + relays: Set, + filter: Filter, + timeoutMs: Long, + ): List { + val client = app.client + val incoming = Channel(UNLIMITED) + val done = mutableSetOf() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + incoming.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done += relay + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done += relay + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + done += relay + } + } + + val collected = mutableListOf() + try { + client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + withTimeoutOrNull(timeoutMs) { + while (done.size < relays.size) { + collected += incoming.receive() + } + while (true) { + val r = incoming.tryReceive() + if (!r.isSuccess) break + collected += r.getOrThrow() + } + } + } finally { + client.unsubscribe(subId) + incoming.close() + } + return collected + } + + private fun MetadataEvent.toProfileHit(): ProfileHit { + val meta = contactMetaData() + return ProfileHit( + npub = NPub.create(pubKey), + pubkeyHex = pubKey, + displayName = meta?.bestName(), + about = meta?.about, + nip05 = meta?.nip05, + picture = meta?.picture, + lnAddress = meta?.lnAddress(), + ) + } + + companion object { + /** + * 6-second drain window. App Functions invocations are user-initiated + * foreground requests in the Gemini UI — anything beyond a few seconds + * is a poor user experience. + */ + private const val GEMINI_DRAIN_TIMEOUT_MS = 6_000L + } +} + +/** + * Single match in [SearchProfilesResult]. Nullable fields let callers + * render whatever subset of metadata the profile happens to publish. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ProfileHit( + /** Bech32 npub identifier (`npub1…`) for the matched profile. */ + val npub: String, + /** Hex-encoded pubkey (same identity as [npub], non-bech32 form). */ + val pubkeyHex: String, + /** Best-effort display name (display_name then name). */ + val displayName: String?, + /** Profile bio / about. */ + val about: String?, + /** NIP-05 verified handle, e.g. `alice@example.com`. */ + val nip05: String?, + /** Avatar image URL. */ + val picture: String?, + /** Lightning address (lud16 preferred, otherwise lud06 LNURL). */ + val lnAddress: String?, +) + +@AppFunctionSerializable(isDescribedByKDoc = true) +class SearchProfilesResult( + /** Matched profiles, deduplicated by pubkey and sorted newest-first. */ + val matches: List, +) { + companion object { + fun empty() = SearchProfilesResult(matches = emptyList()) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 8d1b957095..4ffcd6c263 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.serialization) + alias(libs.plugins.googleKsp) apply false } // Shared app version for all subprojects — read from gradle/libs.versions.toml. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24ca396591..7465579767 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,12 +83,21 @@ sqlite = "2.6.2" ktor = "3.4.3" fourkoma = "1.2.0" +# Phase 2 (Gemini App Functions) — both still pre-stable as of May 2026. +# Scoped to the play flavor only (see amethyst/build.gradle.kts) so the +# fdroid channel doesn't pull in Google alpha dependencies. +appfunctions = "1.0.0-alpha09" +ksp = "2.3.8" + [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } accompanist-adaptive = { group = "com.google.accompanist", name = "accompanist-adaptive", version.ref = "accompanistAdaptive" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" } +androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" } +androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" } androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" } androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" } @@ -213,3 +222,4 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +googleKsp = { id = "com.google.devtools.ksp", version.ref = "ksp" } From 54b09ea6e25bd15ecb6b752d60e2dc2ff9864feb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 21:10:30 +0000 Subject: [PATCH 05/21] fix(commons): split-aware zap requests stop misrouting funds on multi-party notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous ZapActions.buildEventZapRequest signed a single zap request to a single recipient. Notes carrying NIP-57 zap-split tags, NIP-53 live-activity host tags, or NIP-89 app-definition metadata expect the payment to be distributed across multiple parties — so `amy zap event` silently overpaid one party and underpaid the rest. The correctness review on the action-set flagged this as the only real bug in the extracted verbs; this commit fixes it. * ZapSplitResolver — new commonMain object mirroring the resolution order in ZapPaymentHandler.kt (splits > live-activity hosts > app metadata > author fallback). Pure logic; pubkey→LN-address lookup is passed in as a suspend lambda so amy reads from its file store and Android reads from LocalCache, no shared cache-coupling. * ZapActions.buildEventZapRequestsForSplits — high-level helper that composes the resolver with per-share LnZapRequestEvent signing. Each request's `relays` tag unions sender + author + recipient inbox relays so the kind:9735 receipt routes to every interested party (matches signAllZapRequests in the Android handler). * amy zap event — rewired to the split-aware path. JSON output now enumerates each recipient with its share, LN address, request id, and BOLT11 invoice (or per-recipient invoice_error). Profile zaps (amy zap user) keep the simple single-recipient path since they have no split tags. Tests: 12 new cases — LN-address splits, weighted pubkey splits, author fallback, drop-silently-on-missing-LN, relay unioning, share rounding. All 41 action tests green; both Android flavors compile. --- .../amethyst/cli/commands/ZapCommand.kt | 109 +++++++- .../amethyst/commons/actions/ZapActions.kt | 75 ++++++ .../commons/actions/ZapSplitResolver.kt | 182 ++++++++++++++ .../commons/actions/ZapActionsTest.kt | 170 +++++++++++++ .../commons/actions/ZapSplitResolverTest.kt | 236 ++++++++++++++++++ 5 files changed, 760 insertions(+), 12 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt index 0e22df61ab..9d864840a6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -114,7 +114,7 @@ object ZapCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--timeout SECS]") val eventId = rest[0] if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex (nevent bech32 not yet supported)") val sats = @@ -132,24 +132,50 @@ object ZapCommand { ctx.store.query(Filter(ids = listOf(eventId), limit = 1)).firstOrNull() ?: return Output.error("not_found", "event $eventId not in local store; sync first or fetch by id") - val metadata = - fetchLatestMetadata(ctx, zappedEvent.pubKey, ctx.bootstrapRelays(), timeoutMs) - ?: return Output.error("not_found", "no kind:0 metadata found for author ${zappedEvent.pubKey}") - val lnAddress = - ZapActions.extractLnAddress(metadata) - ?: return Output.error("no_lightning", "event author has no lud16 or lud06 in their profile") + val bootstrap = ctx.bootstrapRelays() - val request = - ZapActions.buildEventZapRequest( + // Resolves a pubkey to an LN address by reading the latest + // kind:0 from the local store, falling back to a relay drain + // when never seen. Mirrors what the Amethyst foreground UI + // pulls out of User.lnAddress(). + val lookupLnAddress: suspend (HexKey) -> String? = { pk -> + fetchLatestMetadata(ctx, pk, bootstrap, timeoutMs) + ?.let(ZapActions::extractLnAddress) + } + + // Recipient's NIP-65 read ("inbox") relays — read-side flag on + // their advertised kind:10002. These get unioned into each + // zap request's `relays` tag so the kind:9735 receipt routes + // to the recipient's clients. Matches `User.inboxRelays()` in + // the Android Account. + val lookupInboxRelays: suspend (HexKey) -> Set = { pk -> + ctx + .relaysOf(pk) + ?.readRelaysNorm() + ?.toSet() + .orEmpty() + } + + val requests = + ZapActions.buildEventZapRequestsForSplits( signer = ctx.signer, zappedEvent = zappedEvent, - amountMillisats = ZapActions.satsToMillisats(sats), - inboxRelays = ctx.outboxRelays(), + totalAmountMillisats = ZapActions.satsToMillisats(sats), + senderInboxRelays = ctx.outboxRelays(), + lookupLnAddress = lookupLnAddress, + lookupInboxRelays = lookupInboxRelays, comment = comment, zapType = zapType, ) - emitZapResult(ctx, sats, lnAddress, comment, request, zapType, zappedEventId = zappedEvent.id) + if (requests.isEmpty()) { + return Output.error( + "no_lightning", + "no payable recipients — neither the author nor any zap-split recipient has a usable LN address", + ) + } + + emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests) return 0 } finally { ctx.close() @@ -197,6 +223,65 @@ object ZapCommand { } } + /** + * Multi-recipient (split-aware) event-zap result emitter. Fetches one + * BOLT11 invoice per [ZapActions.ZapRequestForSplit] and writes a + * single JSON object enumerating each recipient + its invoice (or + * per-recipient `invoice_error` when the LNURL fetch fails). Total + * sat sum may be a few millisats below the requested amount due to + * whole-sat rounding in the split shares. + */ + private suspend fun emitSplitZapResult( + ctx: Context, + sats: Long, + comment: String, + zappedEventId: HexKey, + zapType: LnZapEvent.ZapType, + requests: List, + ) { + val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx)) + + val recipientEntries = + requests.map { req -> + val shareSats = req.amountMillisats / 1000 + val result = + resolver.fetchInvoice( + lnAddress = req.recipient.lnAddress, + milliSats = req.amountMillisats, + message = comment, + zapRequest = req.request, + ) + val entry = + mutableMapOf( + "ln_address" to req.recipient.lnAddress, + "pubkey" to req.recipient.pubkey, + "weight" to req.recipient.weight, + "amount_sats" to shareSats, + "zap_request_id" to req.request.id, + ) + when (result) { + is LightningAddressResolver.Result.Success -> + entry["invoice"] = result.invoice + + is LightningAddressResolver.Result.Error -> + entry["invoice_error"] = result.message + } + entry + } + + Output.emit( + mapOf( + "zapped_event_id" to zappedEventId, + "zap_type" to zapType.name.lowercase(), + "comment" to comment, + "requested_sats" to sats, + "billed_sats" to recipientEntries.sumOf { (it["amount_sats"] as? Long) ?: 0L }, + "recipient_count" to recipientEntries.size, + "recipients" to recipientEntries, + ), + ) + } + private fun parseZapType(args: Args): LnZapEvent.ZapType = when { args.bool("anon") -> LnZapEvent.ZapType.ANONYMOUS diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt index 54136a2e76..36f9cc96a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt @@ -90,6 +90,12 @@ object ZapActions { * [toUserPubkey] when the payment should go to a co-author or * delegated recipient (zap splits); when null the zap targets * `zappedEvent.pubKey`. + * + * **Caller beware:** This builds a single zap request to a single + * recipient. Notes carrying NIP-57 zap-split tags, NIP-53 + * live-activity hosts, or NIP-89 app metadata expect the payment to + * be divided across multiple parties. Use [buildEventZapRequestsForSplits] + * for the split-aware path; that's what the Amethyst foreground UI does. */ suspend fun buildEventZapRequest( signer: NostrSigner, @@ -113,4 +119,73 @@ object ZapActions { amountMillisats = amountMillisats, lnurl = lnurl, ) + + /** + * One signed zap request for one split recipient, with the share of + * the total payment already computed. + */ + data class ZapRequestForSplit( + val recipient: ZapSplitResolver.Recipient, + val amountMillisats: Long, + val request: LnZapRequestEvent, + ) + + /** + * Split-aware version of [buildEventZapRequest]: resolves the recipient + * list via [ZapSplitResolver], computes per-recipient shares with + * [ZapSplitResolver.shareMillisats] (rounded to whole sats — matches the + * Amethyst UI), and signs one zap request per recipient. + * + * Each request's relay-list tag includes [senderInboxRelays] union the + * recipient's own inbox relays (resolved via [lookupInboxRelays]), so + * the eventual kind:9735 zap receipt is published to both parties' + * read-side relays. This matches `ZapPaymentHandler.signAllZapRequests`. + * + * Sum of returned `amountMillisats` may differ from [totalAmountMillisats] + * by a few hundred millisats due to whole-sat rounding — same drift the + * in-app flow has. + * + * Recipients with no resolvable LN address are dropped at the resolver + * step; callers that want to surface "missing LN" warnings should call + * [ZapSplitResolver.resolve] separately first. + */ + suspend fun buildEventZapRequestsForSplits( + signer: NostrSigner, + zappedEvent: Event, + totalAmountMillisats: Long, + senderInboxRelays: Set, + lookupLnAddress: suspend (HexKey) -> String?, + lookupInboxRelays: suspend (HexKey) -> Set = { emptySet() }, + comment: String = "", + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + pollOption: Int? = null, + ): List { + val recipients = ZapSplitResolver.resolve(zappedEvent, lookupLnAddress) + if (recipients.isEmpty()) return emptyList() + val totalWeight = recipients.sumOf { it.weight } + + // Author inbox always travels with the zap so the author's clients + // see the receipt even when paying a split recipient. Mirrors the + // `authorRelayList + userRelayList` union in ZapPaymentHandler. + val authorInbox = lookupInboxRelays(zappedEvent.pubKey) + + return recipients.map { recipient -> + val share = ZapSplitResolver.shareMillisats(totalAmountMillisats, recipient.weight, totalWeight) + val recipientInbox = recipient.pubkey?.let { lookupInboxRelays(it) }.orEmpty() + val allRelays = senderInboxRelays + recipientInbox + authorInbox + val request = + LnZapRequestEvent.create( + zappedEvent = zappedEvent, + relays = allRelays, + signer = signer, + pollOption = pollOption, + message = comment, + zapType = zapType, + toUserPubHex = recipient.pubkey, + amountMillisats = share, + lnurl = null, + ) + ZapRequestForSplit(recipient = recipient, amountMillisats = share, request = request) + } + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt new file mode 100644 index 0000000000..06f9484e19 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import kotlin.math.round + +/** + * Resolves the set of recipients for a NIP-57 zap on a given event. + * + * Mirrors the split-resolution logic in Amethyst's + * `service/ZapPaymentHandler.kt` (Android) so non-UI callers — amy CLI, + * Gemini App Functions adapter, automation scripts — pay the same + * recipients the in-app flow would. Without this resolver, a naive + * "zap the event author" path silently misroutes funds on any note that + * carries `zap` tags, live-activity host tags, or app-definition metadata. + * + * The resolution order matches Amethyst: + * 1. NIP-57 zap-split tags on the event (`["zap", ...]`). + * 2. NIP-53 live-activity hosts (kind:30311 only). + * 3. NIP-89 app definition's own LN address (kind:31990 only). + * 4. The event author as the sole recipient. + * + * Recipients without a resolvable LN address are dropped silently — the + * caller is responsible for surfacing that to the user. This matches the + * `mapNotNull` shape of the in-app flow. + */ +object ZapSplitResolver { + /** + * One zap recipient. The total payment is divided among recipients + * proportional to [weight] / sum(weights); [shareMillisats] applies + * the same rounding the in-app flow does. + */ + data class Recipient( + /** LN address ready to hand to [shareMillisats] + an LNURL-pay flow. */ + val lnAddress: String, + /** Pubkey of the recipient, or null when the split tag carried only an LN address. */ + val pubkey: HexKey?, + /** Relative weight in the split. 1.0 when not otherwise specified. */ + val weight: Double, + /** Relay hint the split tag carried, if any — for receipt routing. */ + val relay: NormalizedRelayUrl?, + ) + + /** + * Rounds a per-split share to whole sats (millisats granularity of 1_000). + * Matches `ZapPaymentHandler.calculateZapValue` so sums line up exactly + * with what an Amethyst user would see on-screen. + */ + fun shareMillisats( + totalMillisats: Long, + weight: Double, + totalWeight: Double, + ): Long { + if (totalWeight <= 0.0) return 0L + val shareValue = totalMillisats * (weight / totalWeight) + return round(shareValue / 1000f).toLong() * 1000 + } + + /** + * Resolve the list of zap recipients for [zappedEvent]. + * + * @param lookupLnAddress called to resolve a pubkey to an LN address. For + * amy this reads kind:0 metadata from the local store; for the Android + * adapter it reads `User.lnAddress()` from the live cache. Return null + * when no LN address is known — the recipient is dropped. + * + * @return ordered list of recipients with LN addresses resolved. Empty + * list when no recipient has a usable LN address. + */ + suspend fun resolve( + zappedEvent: Event, + lookupLnAddress: suspend (HexKey) -> String?, + ): List { + val splits = zappedEvent.zapSplitSetup() + + val raw: List = + when { + splits.isNotEmpty() -> + splits.map { setup -> + when (setup) { + is ZapSplitSetupLnAddress -> + Recipient( + lnAddress = setup.lnAddress, + pubkey = null, + weight = setup.weight, + relay = null, + ) + is ZapSplitSetup -> { + val ln = lookupLnAddress(setup.pubKeyHex) + if (ln != null) { + Recipient( + lnAddress = ln, + pubkey = setup.pubKeyHex, + weight = setup.weight, + relay = setup.relay, + ) + } else { + null + } + } + } + } + + zappedEvent is LiveActivitiesEvent && zappedEvent.hasHost() -> + zappedEvent.hosts().map { host -> + val ln = lookupLnAddress(host.pubKey) + if (ln != null) { + Recipient( + lnAddress = ln, + pubkey = host.pubKey, + weight = 1.0, + relay = host.relayHint, + ) + } else { + null + } + } + + zappedEvent is AppDefinitionEvent -> { + val appLn = zappedEvent.appMetaData()?.lnAddress() + val ln = appLn ?: lookupLnAddress(zappedEvent.pubKey) + if (ln != null) { + listOf( + Recipient( + lnAddress = ln, + // appMetaData has no pubkey association; only attribute when we fell back to the author. + pubkey = if (appLn == null) zappedEvent.pubKey else null, + weight = 1.0, + relay = null, + ), + ) + } else { + listOf(null) + } + } + + else -> { + val ln = lookupLnAddress(zappedEvent.pubKey) + if (ln != null) { + listOf( + Recipient( + lnAddress = ln, + pubkey = zappedEvent.pubKey, + weight = 1.0, + relay = null, + ), + ) + } else { + listOf(null) + } + } + } + + return raw.filterNotNull() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt index acb37eda87..eaa21809b1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt @@ -206,4 +206,174 @@ class ZapActionsTest { val pTag = request.tags.firstOrNull { it[0] == "p" } assertEquals(splitTo, pTag?.getOrNull(1), "explicit toUserPubkey wins over event.pubKey") } + + // ------------------------------------------------------------------ + // buildEventZapRequestsForSplits — covers the correctness bug the + // single-recipient buildEventZapRequest has for split notes. + // ------------------------------------------------------------------ + + @Test + fun buildEventZapRequestsForSplits_lnAddressSplitTagsProduceOneRequestPerRecipient() = + runTest { + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = + arrayOf( + arrayOf("zap", "alice@wallet.example"), + arrayOf("zap", "bob@wallet.example"), + ), + content = "split me 50/50", + ) + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 10_000L, + senderInboxRelays = setOf(relay), + lookupLnAddress = { null }, + ) + + assertEquals(2, requests.size) + assertEquals(setOf("alice@wallet.example", "bob@wallet.example"), requests.map { it.recipient.lnAddress }.toSet()) + // LnAddress-style splits are always weight 1.0 (per quartz parser), + // so 10000 msats / 2 = 5000 msats each. + assertEquals(setOf(5_000L), requests.map { it.amountMillisats }.toSet()) + } + + @Test + fun buildEventZapRequestsForSplits_pubkeySplitsRespectWeights() = + runTest { + val splitAPriv = "000000000000000000000000000000000000000000000000000000000000000d" + val splitAPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitAPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val splitBPriv = "0000000000000000000000000000000000000000000000000000000000000011" + val splitBPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitBPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = + arrayOf( + arrayOf("zap", splitAPub, "", "1.0"), + arrayOf("zap", splitBPub, "", "4.0"), + ), + content = "20/80 split", + ) + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 100_000L, // 100 sats + senderInboxRelays = setOf(relay), + lookupLnAddress = { pk -> + when (pk) { + splitAPub -> "a@wallet" + splitBPub -> "b@wallet" + else -> null + } + }, + ) + + val byPub = requests.associateBy { it.recipient.pubkey } + assertEquals(20_000L, byPub[splitAPub]?.amountMillisats, "1/5 of 100 sats") + assertEquals(80_000L, byPub[splitBPub]?.amountMillisats, "4/5 of 100 sats") + // Sum matches input within rounding. + assertEquals(100_000L, requests.sumOf { it.amountMillisats }) + } + + @Test + fun buildEventZapRequestsForSplits_unionsAuthorAndRecipientInboxRelays() = + runTest { + // Use a key distinct from authorPriv/senderPriv so the split + // recipient and the note author are different pubkeys — otherwise + // their inbox-relay lookups collide and we can't tell which one + // ended up in the relays tag. + val splitPriv = "0000000000000000000000000000000000000000000000000000000000000019" + val splitPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = arrayOf(arrayOf("zap", splitPub, "", "1.0")), + content = "test inbox unioning", + ) + + val senderRelay = relay + val authorRelay = + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull("wss://author-inbox.example")!! + val recipientRelay = + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull("wss://recipient-inbox.example")!! + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 1_000L, + senderInboxRelays = setOf(senderRelay), + lookupLnAddress = { _ -> "x@wallet" }, + lookupInboxRelays = { pk -> + when (pk) { + authorSigner.pubKey -> setOf(authorRelay) + splitPub -> setOf(recipientRelay) + else -> emptySet() + } + }, + ) + + assertEquals(1, requests.size) + val relaysTag = requests[0].request.tags.firstOrNull { it[0] == "relays" } + assertNotNull(relaysTag) + val relayUrls = relaysTag.drop(1).toSet() + // All three sources end up in the kind:9734 `relays` tag. + assertTrue(senderRelay.url in relayUrls, "sender inbox missing") + assertTrue(authorRelay.url in relayUrls, "author inbox missing") + assertTrue(recipientRelay.url in relayUrls, "recipient inbox missing") + } + + @Test + fun buildEventZapRequestsForSplits_emptyWhenNoRecipientHasLnAddress() = + runTest { + val splitPriv = "000000000000000000000000000000000000000000000000000000000000000d" + val splitPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = arrayOf(arrayOf("zap", splitPub, "", "1.0")), + content = "no recipient ln", + ) + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 10_000L, + senderInboxRelays = setOf(relay), + lookupLnAddress = { null }, + ) + + assertTrue(requests.isEmpty()) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt new file mode 100644 index 0000000000..7fdca3f94d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ZapSplitResolverTest { + private val authorPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val splitAPriv = "000000000000000000000000000000000000000000000000000000000000000d" + private val splitBPriv = "0000000000000000000000000000000000000000000000000000000000000011" + + private val authorSigner = NostrSignerInternal(KeyPair(authorPriv.hexToByteArray())) + private val authorPub = xOnly(authorPriv) + private val splitAPub = xOnly(splitAPriv) + private val splitBPub = xOnly(splitBPriv) + + private fun xOnly(privHex: String) = + Secp256k1Instance + .compressedPubKeyFor(privHex.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + /** Build a kind:1 note with the given extra tags, signed by the author. */ + private suspend fun noteWithTags(vararg tags: Array): Event = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = TextNoteEvent.KIND, + tags = arrayOf(*tags), + content = "hello world", + ) + + // ------------------------------------------------------------------ + // shareMillisats + // ------------------------------------------------------------------ + + @Test + fun shareMillisats_distributesProportionallyAndRoundsToSats() { + val total = 10_000L // 10 sats — millisats + val a = ZapSplitResolver.shareMillisats(total, weight = 1.0, totalWeight = 4.0) + val b = ZapSplitResolver.shareMillisats(total, weight = 3.0, totalWeight = 4.0) + + // Always a multiple of 1000 (whole sats). + assertEquals(0L, a % 1000) + assertEquals(0L, b % 1000) + // 1/4 + 3/4 = full sat total (within rounding). + assertTrue(a + b in (total - 1000)..(total + 1000)) + } + + @Test + fun shareMillisats_zeroTotalWeightReturnsZero() { + assertEquals(0L, ZapSplitResolver.shareMillisats(1_000L, 1.0, 0.0)) + } + + @Test + fun shareMillisats_roundsHalfUpToWholeSat() { + // 1234 msats with weight 1/1 → 1234 msats, rounds to 1000 msats (1 sat). + val r = ZapSplitResolver.shareMillisats(1_234L, 1.0, 1.0) + assertEquals(1_000L, r) + } + + // ------------------------------------------------------------------ + // resolve — author fallback + // ------------------------------------------------------------------ + + @Test + fun resolve_authorFallbackWhenNoSplitsAndNoSpecialEventKind() = + runTest { + val note = noteWithTags() + val lookup: suspend (HexKey) -> String? = { pk -> + if (pk == authorPub) "author@wallet.example" else null + } + + val recipients = ZapSplitResolver.resolve(note, lookup) + + assertEquals(1, recipients.size) + assertEquals("author@wallet.example", recipients[0].lnAddress) + assertEquals(authorPub, recipients[0].pubkey) + assertEquals(1.0, recipients[0].weight) + } + + @Test + fun resolve_authorWithoutLnAddressReturnsEmpty() = + runTest { + val note = noteWithTags() + val recipients = ZapSplitResolver.resolve(note) { null } + assertTrue(recipients.isEmpty(), "author has no LN address → no recipients") + } + + // ------------------------------------------------------------------ + // resolve — LN-address split tags (legacy variant) + // ------------------------------------------------------------------ + + @Test + fun resolve_lnAddressSplitTagsUsedDirectlyWithoutLookup() = + runTest { + val note = + noteWithTags( + arrayOf("zap", "carol@damus.io"), + arrayOf("zap", "dave@wallet.io"), + ) + // Lookup should never be consulted for LnAddress-style splits. + var lookupCalls = 0 + val recipients = + ZapSplitResolver.resolve(note) { _ -> + lookupCalls++ + null + } + + assertEquals(0, lookupCalls) + assertEquals(2, recipients.size) + assertEquals(setOf("carol@damus.io", "dave@wallet.io"), recipients.map { it.lnAddress }.toSet()) + // LnAddress splits never carry a pubkey. + assertTrue(recipients.all { it.pubkey == null }) + // The legacy LnAddress format is always weight 1.0 per ZapSplitSetupParser. + assertTrue(recipients.all { it.weight == 1.0 }) + } + + // ------------------------------------------------------------------ + // resolve — pubkey split tags (current variant) + // ------------------------------------------------------------------ + + @Test + fun resolve_pubkeySplitTagsResolvedViaLookup() = + runTest { + val note = + noteWithTags( + arrayOf("zap", splitAPub, "", "2.0"), + arrayOf("zap", splitBPub, "", "3.0"), + ) + val knownAddresses = + mapOf( + splitAPub to "split-a@wallet.example", + splitBPub to "split-b@wallet.example", + ) + + val recipients = ZapSplitResolver.resolve(note) { pk -> knownAddresses[pk] } + + assertEquals(2, recipients.size) + val byPub = recipients.associateBy { it.pubkey } + assertEquals("split-a@wallet.example", byPub[splitAPub]?.lnAddress) + assertEquals(2.0, byPub[splitAPub]?.weight) + assertEquals("split-b@wallet.example", byPub[splitBPub]?.lnAddress) + assertEquals(3.0, byPub[splitBPub]?.weight) + } + + @Test + fun resolve_pubkeySplitWithoutLnAddressIsDroppedSilently() = + runTest { + val note = + noteWithTags( + arrayOf("zap", splitAPub, "", "1.0"), + arrayOf("zap", splitBPub, "", "1.0"), + ) + // Only split A has an LN address; B is silently dropped — same + // behavior as the in-app `mapNotNull` after error display. + val recipients = + ZapSplitResolver.resolve(note) { pk -> + if (pk == splitAPub) "split-a@wallet.example" else null + } + + assertEquals(1, recipients.size) + assertEquals(splitAPub, recipients[0].pubkey) + } + + @Test + fun resolve_pubkeySplitsDoNotFallBackToAuthor() = + runTest { + // When split tags are present but none resolve to an LN address, + // we get empty — we do NOT silently bill the author. + val note = noteWithTags(arrayOf("zap", splitAPub, "", "1.0")) + + val recipients = ZapSplitResolver.resolve(note) { null } + + assertTrue(recipients.isEmpty()) + } + + // ------------------------------------------------------------------ + // shareMillisats integration: weighted distribution sums correctly + // ------------------------------------------------------------------ + + @Test + fun resolveAndShare_weighted2to3SplitMatchesUiBehavior() = + runTest { + val note = + noteWithTags( + arrayOf("zap", splitAPub, "", "2.0"), + arrayOf("zap", splitBPub, "", "3.0"), + ) + val recipients = + ZapSplitResolver.resolve(note) { pk -> + when (pk) { + splitAPub -> "a@x" + splitBPub -> "b@x" + else -> null + } + } + val totalWeight = recipients.sumOf { it.weight } + val totalMsats = 100_000L // 100 sats + + val shares = recipients.map { ZapSplitResolver.shareMillisats(totalMsats, it.weight, totalWeight) } + + // 2/5 of 100 sats = 40 sats; 3/5 = 60 sats. + assertEquals(40_000L, shares[0]) + assertEquals(60_000L, shares[1]) + assertEquals(totalMsats, shares.sum()) + } +} From 29236d78018b80e1394b28d09e9d7b0ac3a5c016 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 21:35:49 +0000 Subject: [PATCH 06/21] chore(commons,cli,amethyst): three correctness wins + caller-responsibility kdoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining items from the comparative review of the extracted actions against the in-app Amethyst flows. All small, all surfaced by the review. * amy follow now stamps the relay hint on new contact-list `p` tags. Best-effort read from the target's cached kind:10002 advertised relay list (first writeRelaysNorm). Mirrors User.bestRelayHint() — follows added via amy no longer have empty relayUri. * amy search user now dedups by pubkey (sorted newest-first) instead of by event id, matching the App Functions adapter. Multiple relays surfacing different kind:0 revisions for the same author collapse to one hit. * AmethystAppFunctions.searchProfiles captures the active account AND the relay client at function entry, then never touches sessionManager or Amethyst.instance again during the drain. Closes the account- switch race surfaced in the review. * FollowActions / SearchActions / ZapActions kdoc now lists the caller-side responsibilities each builder leaves to the consumer (publish, writeable check, relay hint, pseudo-kind filtering, LN round-trip, receipt verification, etc.). Documents the design rather than letting it leak through reviews. --- .../appfunctions/AmethystAppFunctions.kt | 14 ++++++---- .../amethyst/cli/commands/FollowCommand.kt | 16 +++++++++++ .../amethyst/cli/commands/SearchCommand.kt | 6 +++++ .../amethyst/commons/actions/FollowActions.kt | 21 ++++++++++++--- .../amethyst/commons/actions/SearchActions.kt | 24 ++++++++++++----- .../amethyst/commons/actions/ZapActions.kt | 27 ++++++++++++++----- 6 files changed, 88 insertions(+), 20 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 2e67499e73..d300b9a7bc 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -78,8 +78,13 @@ class AmethystAppFunctions { val cappedLimit = limit.coerceIn(1, 50) val filter = SearchActions.searchProfilesFilter(query, cappedLimit) ?: return SearchProfilesResult.empty() - val app = Amethyst.instance - val account = app.sessionManager.loggedInAccount() ?: return SearchProfilesResult.empty() + // Snapshot the active account + relay set + client at function entry + // and never touch sessionManager again from this dispatch. If the + // user switches account mid-drain, this snapshot keeps the request + // routed to the relays we originally queried — caller still gets a + // coherent result rather than events mixed across accounts. + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchProfilesResult.empty() + val client = Amethyst.instance.client // SearchRelayListState's flow already resolves to a concrete relay // set: NIP-44-decrypted private entries + public entries, or the @@ -88,7 +93,7 @@ class AmethystAppFunctions { val relays = account.searchRelayList.flow.value if (relays.isEmpty()) return SearchProfilesResult.empty() - val events = drain(app, relays, filter, GEMINI_DRAIN_TIMEOUT_MS) + val events = drain(client, relays, filter, GEMINI_DRAIN_TIMEOUT_MS) val hits = events @@ -108,12 +113,11 @@ class AmethystAppFunctions { * Account exposes a live `INostrClient` rather than a drain helper. */ private suspend fun drain( - app: com.vitorpamplona.amethyst.AppModules, + client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient, relays: Set, filter: Filter, timeoutMs: Long, ): List { - val client = app.client val incoming = Channel(UNLIMITED) val done = mutableSetOf() val subId = newSubId() diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt index 5de60d2097..c96234c8f4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt @@ -85,6 +85,21 @@ object FollowCommand { val latest = fetchLatestContactList(ctx, self, outbox, timeoutSecs * 1000) val previouslyFollowed = latest?.isTaggedUser(target) ?: false + // Relay hint embedded in the `p` tag for new follows — points + // readers at a relay where they'll find the target's events. + // Best-effort: first write relay from the target's cached + // kind:10002 advertised relay list, null if we've never seen + // one. Mirrors User.bestRelayHint() in the Android UI. + val targetRelayHint = + if (op == FollowOp.FOLLOW) { + ctx + .relaysOf(target) + ?.writeRelaysNorm() + ?.firstOrNull() + } else { + null + } + val newEvent: ContactListEvent? = when (op) { FollowOp.FOLLOW -> @@ -92,6 +107,7 @@ object FollowCommand { signer = ctx.signer, pubkeyToFollow = target, currentContactList = latest, + relayHint = targetRelayHint, ) FollowOp.UNFOLLOW -> FollowActions.buildUnfollow( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt index 00b29ff9b7..0c52732a96 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt @@ -79,6 +79,12 @@ object SearchCommand { return runSearch(dataDir, query, filter, timeoutMs) { events -> events .mapNotNull { it as? MetadataEvent } + // Dedup by pubkey, not event id — multiple relays may return + // different kind:0 revisions for the same author; keep only + // the freshest. Matches the App Functions adapter so amy + // and Gemini surface the same profile count for a query. + .sortedByDescending { it.createdAt } + .distinctBy { it.pubKey } .map { ev -> val parsed = try { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt index 6b8c501a8e..76ca908515 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt @@ -29,9 +29,24 @@ import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag /** * Pure event-building "verbs" for the NIP-02 kind:3 contact list. * - * Builds a signed [ContactListEvent] but does NOT publish it — callers are - * responsible for relay delivery (e.g. `Account.sendMyPublicAndPrivateOutbox` - * on Android, `Context.publish` in amy). + * Builds a signed [ContactListEvent] but does NOT publish it. The Amethyst + * Android UI flow does more than these builders — non-UI callers are + * responsible for the rest: + * + * * **Publish.** Hand the returned event to your relay client. Android + * uses `Account.sendMyPublicAndPrivateOutbox`, amy uses `Context.publish`. + * * **Writeable check.** Skip the call when the active signer is read-only + * (e.g. an npub-only login). Building will fail at the sign step + * otherwise. + * * **Relay hint.** Pass [relayHint] pointing at one of the target's + * advertised kind:10002 write relays so readers can find the followed + * user. The in-app flow does this via `User.bestRelayHint()`. + * * **No-op detection.** When the user already follows the target, the + * underlying builder short-circuits to the same [currentContactList]. + * Compare `result.id == currentContactList?.id` to detect this. + * * **Local cache update.** If your caller has a local event cache, feed + * the new event back in so the UI / next read sees the update without + * a relay round-trip. * * Canonical entry point for non-UI callers (CLI commands, Android App * Functions adapters, automation scripts): takes pubkeys as [HexKey] rather diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt index 78f6acbcef..2fb16fd2be 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt @@ -31,13 +31,25 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent /** * Pure NIP-50 search-filter assembly + search-relay resolution. * - * Like [FollowActions], these are non-UI verbs callable from any context - * (amy CLI, future Android App Functions adapter for Gemini, automation). - * The actions only build [Filter]s and pick relays — caller drives the - * actual subscription / drain via its own relay client. + * Builds [Filter]s and picks relays — caller drives subscription / drain. + * Non-UI callers should layer the following on top to match Amethyst's + * in-app search behavior: * - * For client-side post-filtering (dedup, pseudo-kinds like `reply` / `media`) - * see [com.vitorpamplona.amethyst.commons.search.SearchResultFilter]. + * * **Drain / subscribe.** A function-call API (amy `search`, Gemini App + * Functions) usually wants `client.subscribe(...)` until every relay + * sends EOSE or a short timeout elapses, then unsubscribe. The Amethyst + * foreground UI uses a live subscription instead — it stays open as the + * user types. + * * **Dedup.** Profile search dedups by `pubKey` (multiple kind:0 events + * per author); note search dedups by event id. Both pick the freshest + * revision via `sortedByDescending { createdAt }.distinctBy { … }`. + * * **Pseudo-kind filtering.** When you let callers ask for `reply` / + * `media` / exclusion terms, apply + * [com.vitorpamplona.amethyst.commons.search.SearchResultFilter] after + * the drain. This filter is NOT exposed in the filter API itself. + * * **Debounce.** Interactive callers should debounce input. The + * Amethyst UI uses 300 ms before issuing a new subscription; one-shot + * callers (amy, App Functions) skip this. */ object SearchActions { /** Default kinds for "search notes" — kind:1 short text notes. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt index 36f9cc96a8..5c6bd6ecc9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt @@ -32,14 +32,29 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent * NIP-57 zap-request building + LN address extraction. * * Returns a signed [LnZapRequestEvent] (kind:9734) — the artifact a caller - * hands to a LNURL-pay callback to receive a BOLT11 invoice. The Lightning - * round-trip (LNURL fetch, invoice retrieval, optional NWC payment) is - * intentionally out of scope here; callers compose this with - * `LightningAddressResolver` (commons/jvmAndroid) or their own LN client. + * hands to a LNURL-pay callback to receive a BOLT11 invoice. + * + * **Caller responsibilities** that the Amethyst Android flow handles but + * these builders do not: + * + * * **Use [buildEventZapRequestsForSplits] for events.** A naive call to + * [buildEventZapRequest] on a note carrying NIP-57 zap-split tags, NIP-53 + * live-activity host tags, or NIP-89 app metadata silently misroutes + * funds to a single recipient. The splits variant is what the + * foreground UI uses and what amy `zap event` calls. + * * **Lightning round-trip.** LNURL endpoint fetch, BOLT11 invoice + * retrieval, and optional NIP-47 NWC payment all live outside these + * builders. `LightningAddressResolver` (in commons/jvmAndroid) covers + * the LNURL + invoice steps. + * * **Receipt verification.** When the kind:9735 receipt arrives, validate + * it against the LNURL provider's `nostrPubkey` (NIP-57 Appendix F) — + * primed via `LnurlEndpointCache` on Android. + * * **Onchain zaps** (NIP-BC) are a separate flow — see `OnchainZapSender` + * in commons. These builders only cover Lightning. * * Pattern matches [FollowActions] and [SearchActions]: shared, pure logic - * usable from amy CLI, the future Android App Functions adapter for Gemini, - * and any other non-UI consumer. + * usable from amy CLI, the Android App Functions adapter for Gemini, and + * any other non-UI consumer. */ object ZapActions { /** Convert sats to millisats — LN-side amount unit. */ From b150556f1ef86a2f210bae51257408a55d6ba37e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 23:10:37 +0000 Subject: [PATCH 07/21] =?UTF-8?q?refactor(amethyst):=20drop=20PlayAmethyst?= =?UTF-8?q?=20=E2=80=94=20appfunctions=20doesn't=20need=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wiring forced Amethyst to be `open`, added a 30-line PlayAmethyst subclass that only implemented AppFunctionConfiguration .Provider, and used tools:replace="android:name" in the play manifest to swap classes. The justification was that the appfunctions runtime discovers @AppFunction host classes via Application.appFunctionConfiguration. Reading the KSP-generated dispatcher ($AmethystAppFunctions_AppFunctionInvoker.kt) shows that's only half true. The invoker passes a default-construction fallback lambda when instantiating the host class, and ConfigurableAppFunctionFactory takes that fallback as a constructor argument. Provider is only consulted to *override* construction — required for classes with non-default constructors, optional otherwise. AmethystAppFunctions has a no-arg constructor, so: * PlayAmethyst is deleted entirely * Amethyst goes back to `class Amethyst : Application()` (no `open`) * Play manifest reverts to plain `android:name=".Amethyst"`, no tools:replace gymnastics Verified by assemblePlayDebug (APK builds clean) and the merged play manifest still pinning the appfunctions service. If we ever add a host class with constructor parameters (an Account-injected one, say), we'll need to add Provider back — kdoc on AmethystAppFunctions documents that. --- .../com/vitorpamplona/amethyst/Amethyst.kt | 2 +- amethyst/src/play/AndroidManifest.xml | 7 +-- .../vitorpamplona/amethyst/PlayAmethyst.kt | 52 ------------------- .../appfunctions/AmethystAppFunctions.kt | 10 +++- 4 files changed, 10 insertions(+), 61 deletions(-) delete mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/PlayAmethyst.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 3ae80f681f..4ac9fefc0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel -open class Amethyst : Application() { +class Amethyst : Application() { init { Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR Log.d("AmethystApp") { "Creating App $this" } diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index 23fc8df4b3..044e757915 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -2,13 +2,8 @@ - + android:name=".Amethyst"> Date: Sun, 24 May 2026 23:16:12 +0000 Subject: [PATCH 08/21] refactor(amethyst): use INostrClient.fetchAll instead of inlining the drain loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quartz already ships an extension that does exactly what AmethystAppFunctions.drain reimplemented — subscribe with the given filters, collect events until every relay sends EOSE / closed / cannot-connect or the timeout elapses, unsubscribe, dedup by id, return sorted newest-first. See quartz/.../relay/client/accessories/NostrClientFetchAllExt.kt. Replacing the local drain with `client.fetchAll(filters, timeoutMs)` trims 70+ lines of subscription listener boilerplate and gives the adapter the same behavior the rest of the codebase already trusts. amy's Context.drain stays — it adds per-event signature verification and persistence to the file event store (the trust boundary for amy) that fetchAll doesn't do. --- .../appfunctions/AmethystAppFunctions.kt | 95 +++---------------- 1 file changed, 13 insertions(+), 82 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index a4f759116e..a0d4ccd41f 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -25,16 +25,9 @@ import androidx.appfunctions.AppFunctionSerializable import androidx.appfunctions.service.AppFunction import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.actions.SearchActions -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED -import kotlinx.coroutines.withTimeoutOrNull /** * Bridge that exposes Amethyst's "verbs" (commons/.../actions/) to the Android @@ -86,7 +79,7 @@ class AmethystAppFunctions { // Snapshot the active account + relay set + client at function entry // and never touch sessionManager again from this dispatch. If the - // user switches account mid-drain, this snapshot keeps the request + // user switches account mid-fetch, this snapshot keeps the request // routed to the relays we originally queried — caller still gets a // coherent result rather than events mixed across accounts. val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchProfilesResult.empty() @@ -99,7 +92,15 @@ class AmethystAppFunctions { val relays = account.searchRelayList.flow.value if (relays.isEmpty()) return SearchProfilesResult.empty() - val events = drain(client, relays, filter, GEMINI_DRAIN_TIMEOUT_MS) + // Quartz's INostrClient.fetchAll handles subscribe → drain on + // EOSE/closed/cannot-connect → unsubscribe → dedup by id → sort + // newest-first. Wraps everything in a withTimeoutOrNull(timeoutMs) + // so a slow relay can't stall the dispatch. + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) val hits = events @@ -112,76 +113,6 @@ class AmethystAppFunctions { return SearchProfilesResult(matches = hits) } - /** - * One-shot relay drain: subscribe with [filter] against [relays], collect - * events until every relay sends EOSE or [timeoutMs] elapses, then - * unsubscribe. Mirrors `Context.drain` in amy — kept inline here because - * Account exposes a live `INostrClient` rather than a drain helper. - */ - private suspend fun drain( - client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient, - relays: Set, - filter: Filter, - timeoutMs: Long, - ): List { - val incoming = Channel(UNLIMITED) - val done = mutableSetOf() - val subId = newSubId() - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - incoming.trySend(event) - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - done += relay - } - - override fun onClosed( - message: String, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - done += relay - } - - override fun onCannotConnect( - relay: NormalizedRelayUrl, - message: String, - forFilters: List?, - ) { - done += relay - } - } - - val collected = mutableListOf() - try { - client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) - withTimeoutOrNull(timeoutMs) { - while (done.size < relays.size) { - collected += incoming.receive() - } - while (true) { - val r = incoming.tryReceive() - if (!r.isSuccess) break - collected += r.getOrThrow() - } - } - } finally { - client.unsubscribe(subId) - incoming.close() - } - return collected - } - private fun MetadataEvent.toProfileHit(): ProfileHit { val meta = contactMetaData() return ProfileHit( @@ -197,11 +128,11 @@ class AmethystAppFunctions { companion object { /** - * 6-second drain window. App Functions invocations are user-initiated + * 6-second fetch window. App Functions invocations are user-initiated * foreground requests in the Gemini UI — anything beyond a few seconds * is a poor user experience. */ - private const val GEMINI_DRAIN_TIMEOUT_MS = 6_000L + private const val GEMINI_FETCH_TIMEOUT_MS = 6_000L } } From 31cfb53b256531377725f38bb3a66c1fa0052e0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 23:45:33 +0000 Subject: [PATCH 09/21] feat(commons): extract NIP-17 DM verbs into shared actions package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth verb extraction alongside FollowActions / SearchActions / ZapActions. Closes the largest remaining amy-expert "thin assembly" violation in cli/. Two pieces moved out of cli/.../DmCommands.kt into commons: * DmActions.resolveDmRelays applies the strict-kind:10050 → NIP-65- read → bootstrap fallback policy the in-app flow uses. Returns a DmRelaySet with a typed RelaySource (KIND_10050 / NIP65_READ / BOOTSTRAP / NONE) so callers can surface the source — amy emits it on stdout, a future Gemini adapter could mention it in the assistant response. * DmActions.buildTextDm / buildFileDmReference are thin wrappers over NIP17Factory.createMessageNIP17 / createEncryptedFileNIP17 that build the kind:14 / kind:15 template and gift-wrap in one call. Matches the FollowActions / ZapActions builder shape. amy's DmCommands is now genuinely thin assembly: requireUserHex, flag plumbing, call DmActions, render JSON. The 583-line file shrank slightly and — more importantly — no longer carries NIP-17 logic the rest of the codebase needs to look at. Receive-side decrypt loop (3 lines of unwrapAndUnsealOrNull) stays in amy; too small to extract and tightly coupled to amy's per-relay attribution. 10 new tests for DmActions: strict/permissive fallback chain, null recipient lists, RelaySource enum stability, and a smoke test that buildTextDm produces a kind:14 with the right wrap count (sender + recipient). --- .../amethyst/cli/commands/DmCommands.kt | 94 ++++---- .../amethyst/commons/actions/DmActions.kt | 171 ++++++++++++++ .../amethyst/commons/actions/DmActionsTest.kt | 214 ++++++++++++++++++ 3 files changed, 433 insertions(+), 46 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt index 9fccae5dcc..9cd003ae30 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.cli.AwaitTimeout import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.actions.DmActions import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator @@ -34,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -86,8 +86,7 @@ object DmCommands { try { ctx.prepare() val recipient = ctx.requireUserHex(rest[0]) - val template = ChatMessageEvent.build(text, listOf(PTag(recipient))) - val result = NIP17Factory().createMessageNIP17(template, ctx.signer) + val result = DmActions.buildTextDm(ctx.signer, recipient, text) return publishWraps(ctx, result, allowFallback) } finally { ctx.close() @@ -124,27 +123,34 @@ object DmCommands { ctx.prepare() val recipient = ctx.requireUserHex(recipientInput) - val (template, summary) = + val (result, summary) = if (args.flag("file") != null) { - buildUploadModeTemplate(ctx, recipient, args) + buildUploadedFileDm(ctx, recipient, args) ?: return 1 } else { - buildReferenceModeTemplate(args, recipient) + buildReferencedFileDm(ctx, recipient, args) ?: return 1 } - val result = NIP17Factory().createEncryptedFileNIP17(template, ctx.signer) return publishWraps(ctx, result, allowFallback, extra = summary) } finally { ctx.close() } } - private suspend fun buildUploadModeTemplate( + /** + * Upload mode: read the local file, encrypt with a fresh AESGCM key, + * push the ciphertext to a Blossom server, then call into + * [DmActions.buildFileDmReference] with the resulting URL + metadata. + * Returns the gift-wrap result plus an `extra` map that surfaces the + * upload's cipher material on stdout so callers can re-share or + * republish the same blob without re-uploading. + */ + private suspend fun buildUploadedFileDm( ctx: Context, - recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey, + recipient: HexKey, args: Args, - ): Pair, Map>? { + ): Pair>? { val file = java.io.File(args.requireFlag("file")) if (!file.exists()) { Output.error("bad_args", "file does not exist: ${file.absolutePath}") @@ -169,9 +175,10 @@ object DmCommands { .DimensionTag(w, h) } } - val template = - ChatMessageEncryptedFileHeaderEvent.build( - to = listOf(PTag(recipient)), + val result = + DmActions.buildFileDmReference( + signer = ctx.signer, + recipient = recipient, url = uploadedUrl, cipher = cipher, mimeType = mimeType, @@ -181,8 +188,6 @@ object DmCommands { blurhash = uploaded.metadata.blurhash, originalHash = uploaded.metadata.sha256, ) - // Surface the cipher material on stdout so callers can re-share - // or republish the same encrypted blob without re-uploading. val summary = mapOf( "url" to uploadedUrl, @@ -193,13 +198,19 @@ object DmCommands { "original_hash" to uploaded.metadata.sha256, "mime_type" to mimeType, ) - return template to summary + return result to summary } - private fun buildReferenceModeTemplate( + /** + * Reference mode: the file is already uploaded somewhere; the user + * hands us the URL + cipher key/nonce + whatever metadata they want + * stamped onto the kind:15. + */ + private suspend fun buildReferencedFileDm( + ctx: Context, + recipient: HexKey, args: Args, - recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey, - ): Pair, Map>? { + ): Pair>? { val url = args.positionalOrNull(0) ?: run { Output.error("bad_args", USAGE_SEND_FILE) @@ -236,9 +247,10 @@ object DmCommands { val cipher = com.vitorpamplona.quartz.utils.ciphers .AESGCM(keyBytes, nonceBytes) - val template = - ChatMessageEncryptedFileHeaderEvent.build( - to = listOf(PTag(recipient)), + val result = + DmActions.buildFileDmReference( + signer = ctx.signer, + recipient = recipient, url = url, cipher = cipher, mimeType = mimeType, @@ -248,7 +260,7 @@ object DmCommands { blurhash = blurhash, originalHash = originalHash, ) - return template to emptyMap() + return result to emptyMap() } private const val USAGE_SEND_FILE: String = @@ -278,7 +290,7 @@ object DmCommands { "wrap_id" to wrap.id, "published_to" to ack.filterValues { it }.keys.map { it.url }, "relays_tried" to resolution.relays.map { it.url }, - "relay_source" to resolution.source, + "relay_source" to resolution.source.name.lowercase(), ), ) } @@ -402,39 +414,29 @@ object DmCommands { } /** - * Per NIP-17: kind:1059 should only be delivered to relays the recipient - * has advertised in their kind:10050. When that list is empty: - * - strict (default): refuse with no_dm_relays — caller must fix or - * explicitly opt into a fallback. - * - allowFallback=true: fall through to the NIP-65 read marker and then - * to our bootstrap pool. + * Cache-first relay lookup. If amy has previously seen the recipient's + * kind:10050 / 10051 / 10002 events, use the local copy and skip the + * network drain entirely. Otherwise drain `seedRelays` for them. Then + * hands the resulting [RecipientRelayFetcher.Lists] off to + * [DmActions.resolveDmRelays] which applies the strict-kind:10050 / + * fallback policy. */ private suspend fun resolveDmRelays( ctx: Context, recipient: HexKey, allowFallback: Boolean, - ): RelaySet { + ): DmActions.DmRelaySet { val seed = ctx.bootstrapRelays() - // Cache-first: if Amy has previously seen the recipient's - // kind:10050 / 10051 / 10002 events, use the local copy and - // skip the network drain entirely. Falls back to the live - // fetcher only if the local store has nothing. val lists = ctx.cachedRelayListsOf(recipient) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed) - val dmInbox = lists.dmInbox.toSet() - if (dmInbox.isNotEmpty()) return RelaySet(dmInbox, "kind_10050") - if (!allowFallback) return RelaySet(emptySet(), "kind_10050") - val nip65Read = lists.nip65Read().toSet() - if (nip65Read.isNotEmpty()) return RelaySet(nip65Read, "nip65_read") - return RelaySet(seed, "bootstrap") + return DmActions.resolveDmRelays( + recipientLists = lists, + bootstrap = seed, + allowFallback = allowFallback, + ) } - private data class RelaySet( - val relays: Set, - val source: String, - ) - private sealed interface DecryptedDm { val id: HexKey val wrapId: HexKey diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt new file mode 100644 index 0000000000..bb820f08a0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.ciphers.AESGCM + +/** + * NIP-17 direct-message verbs — relay resolution policy + gift-wrap builders. + * + * Like [FollowActions] / [SearchActions] / [ZapActions], this is pure logic + * usable from amy CLI, the Android App Functions adapter for Gemini, and any + * other non-UI consumer. The send builders return signed gift wraps but do + * NOT publish; the read side (decrypting incoming gift wraps) stays at the + * caller because the `unwrapAndUnsealOrNull` extension in + * `commons/.../relayClient/nip17Dm/` is already a one-liner. + * + * **Caller responsibilities** that this object leaves to the consumer: + * + * * **Publish.** Each wrap goes to its own recipient's DM-relay set — + * resolve via [resolveDmRelays] and hand each wrap to your relay client. + * * **Recipient resolution.** Translate npub / NIP-05 / hex to [HexKey] + * before calling — the Android UI uses `User.pubkeyHex`, amy uses + * `Context.requireUserHex`, the Gemini adapter would resolve through + * its own NIP-05 path. + * * **File upload (kind:15).** [buildFileDmReference] assumes the file is + * already at a URL. For "upload-then-DM", use + * `commons/.../service/upload/UploadOrchestrator` (jvmAndroid only, has + * an OkHttp dep) before calling here. + * * **Receipt of incoming DMs.** The kind:1059 gift-wrap drain, NIP-44 + * unseal, and decrypt-to-inner-event step is a 3-line caller-side loop + * over [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull] + * — too small to bother extracting. + */ +object DmActions { + /** + * Source bucket from which [DmRelaySet.relays] was drawn. Useful for + * surfacing "where did we deliver?" telemetry to the caller — amy + * emits this on stdout, Gemini could mention it in the assistant + * response. + */ + enum class RelaySource { + /** Recipient's NIP-17 inbox (kind:10050). The strict NIP-17 path. */ + KIND_10050, + + /** NIP-65 read marker (kind:10002 read relays). Fallback bucket. */ + NIP65_READ, + + /** Caller-provided bootstrap pool. Last-resort fallback. */ + BOOTSTRAP, + + /** No relays available — caller should refuse to send. */ + NONE, + } + + /** Outcome of [resolveDmRelays]: the relays to publish to, plus which bucket they came from. */ + data class DmRelaySet( + val relays: Set, + val source: RelaySource, + ) + + /** + * Apply Amethyst's NIP-17 relay-resolution policy to a recipient. + * + * NIP-17 says clients "shouldn't try" to deliver a gift wrap unless the + * recipient has published a kind:10050. In strict mode (the default), an + * empty kind:10050 returns [RelaySource.NONE] so the caller refuses to + * send. Permissive mode walks the fallback chain instead — NIP-65 read + * relays, then the bootstrap pool — for cases like interop tests and + * brand-new accounts where strict mode is too strict. + * + * @param recipientLists the recipient's relay-list snapshot from + * [RecipientRelayFetcher.fetchRelayLists] (or a local cache). + * Pass null when the recipient is unknown — same effect as empty lists. + * @param bootstrap the caller's bootstrap relay pool, used as the + * last-resort fallback when [allowFallback] is true. + * @param allowFallback opt into the NIP-65-read → bootstrap chain when + * kind:10050 is empty. Default false (strict mode). + */ + fun resolveDmRelays( + recipientLists: RecipientRelayFetcher.Lists?, + bootstrap: Set, + allowFallback: Boolean = false, + ): DmRelaySet { + val dmInbox = recipientLists?.dmInbox?.toSet().orEmpty() + if (dmInbox.isNotEmpty()) return DmRelaySet(dmInbox, RelaySource.KIND_10050) + if (!allowFallback) return DmRelaySet(emptySet(), RelaySource.NONE) + val nip65Read = recipientLists?.nip65Read()?.toSet().orEmpty() + if (nip65Read.isNotEmpty()) return DmRelaySet(nip65Read, RelaySource.NIP65_READ) + return DmRelaySet(bootstrap, RelaySource.BOOTSTRAP) + } + + /** + * Build a NIP-17 text DM (kind:14) wrapped in a NIP-59 gift wrap per + * recipient. The returned [NIP17Factory.Result] carries the inner + * event for local caching and one gift wrap per recipient (just one + * here — the recipient + the sender's own copy). Caller publishes each + * wrap to that recipient's DM-relay set. + */ + suspend fun buildTextDm( + signer: NostrSigner, + recipient: HexKey, + text: String, + ): NIP17Factory.Result { + val template = ChatMessageEvent.build(text, listOf(PTag(recipient))) + return NIP17Factory().createMessageNIP17(template, signer) + } + + /** + * Build a NIP-17 encrypted-file DM (kind:15) for a file that has + * already been uploaded to [url]. The [cipher]'s key + nonce travel + * inside the gift-wrapped inner event so only the recipient — and the + * sender, who keeps their own copy — can decrypt the bytes at [url]. + * + * Pre-uploaded URL only: the upload step is jvmAndroid-only (needs + * OkHttp). For "upload then DM" use `UploadOrchestrator` first and + * pass its returned URL + the cipher you generated here. + */ + suspend fun buildFileDmReference( + signer: NostrSigner, + recipient: HexKey, + url: String, + cipher: AESGCM, + mimeType: String? = null, + hash: String? = null, + originalHash: String? = null, + size: Int? = null, + dimension: DimensionTag? = null, + blurhash: String? = null, + ): NIP17Factory.Result { + val template = + ChatMessageEncryptedFileHeaderEvent.build( + to = listOf(PTag(recipient)), + url = url, + cipher = cipher, + mimeType = mimeType, + hash = hash, + size = size, + dimension = dimension, + blurhash = blurhash, + originalHash = originalHash, + ) + return NIP17Factory().createEncryptedFileNIP17(template, signer) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt new file mode 100644 index 0000000000..55b0550a6e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +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.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DmActionsTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val recipientPriv = "0000000000000000000000000000000000000000000000000000000000000019" + private val signer = NostrSignerInternal(KeyPair(senderPriv.hexToByteArray())) + private val recipientPub = + Secp256k1Instance + .compressedPubKeyFor(recipientPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private val dmInbox = relay("wss://dm-inbox.example") + private val nip65ReadRelay = relay("wss://nip65-read.example") + private val nip65WriteRelay = relay("wss://nip65-write.example") + private val bootstrap = setOf(relay("wss://bootstrap.example")) + + private fun relay(url: String) = RelayUrlNormalizer.normalizeOrNull(url)!! + + /** Build a kind:10002 with one read + one write relay so [nip65Read] returns + * the expected single URL. */ + private suspend fun nip65WithReadAndWrite(): AdvertisedRelayListEvent = + signer.sign( + createdAt = 1_700_000_000L, + kind = AdvertisedRelayListEvent.KIND, + tags = + arrayOf( + AdvertisedRelayInfo.assemble(nip65ReadRelay, AdvertisedRelayType.READ), + AdvertisedRelayInfo.assemble(nip65WriteRelay, AdvertisedRelayType.WRITE), + ), + content = "", + ) + + // ------------------------------------------------------------------ + // resolveDmRelays — strict (default) mode + // ------------------------------------------------------------------ + + @Test + fun resolveDmRelays_strictReturnsKind10050WhenPresent() { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = listOf(dmInbox), + keyPackage = emptyList(), + nip65 = null, + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = false) + + assertEquals(setOf(dmInbox), result.relays) + assertEquals(DmActions.RelaySource.KIND_10050, result.source) + } + + @Test + fun resolveDmRelays_strictReturnsNoneWhenKind10050Empty() { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = null, + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = false) + + // NIP-17 strict mode: no kind:10050 → refuse to deliver. Caller + // surfaces a no_dm_relays error rather than guessing. + assertTrue(result.relays.isEmpty()) + assertEquals(DmActions.RelaySource.NONE, result.source) + } + + @Test + fun resolveDmRelays_strictReturnsNoneEvenWhenNip65Present() = + runTest { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = nip65WithReadAndWrite(), + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = false) + + // Strict mode does not fall through to NIP-65 even if it's present. + assertTrue(result.relays.isEmpty()) + assertEquals(DmActions.RelaySource.NONE, result.source) + } + + // ------------------------------------------------------------------ + // resolveDmRelays — permissive (allowFallback=true) mode + // ------------------------------------------------------------------ + + @Test + fun resolveDmRelays_fallbackPrefersKind10050OverNip65() = + runTest { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = listOf(dmInbox), + keyPackage = emptyList(), + nip65 = nip65WithReadAndWrite(), + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = true) + + // kind:10050 wins even with fallback enabled — it's still the strict path. + assertEquals(setOf(dmInbox), result.relays) + assertEquals(DmActions.RelaySource.KIND_10050, result.source) + } + + @Test + fun resolveDmRelays_fallbackUsesNip65ReadWhenKind10050Empty() = + runTest { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = nip65WithReadAndWrite(), + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = true) + + // Falls through to NIP-65 read relays — not write — matching User.inboxRelays(). + assertEquals(setOf(nip65ReadRelay), result.relays) + assertEquals(DmActions.RelaySource.NIP65_READ, result.source) + } + + @Test + fun resolveDmRelays_fallbackReachesBootstrapWhenNothingElsePresent() { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = null, + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = true) + + assertEquals(bootstrap, result.relays) + assertEquals(DmActions.RelaySource.BOOTSTRAP, result.source) + } + + @Test + fun resolveDmRelays_nullListsTreatedAsEmpty() { + val resultStrict = DmActions.resolveDmRelays(null, bootstrap = bootstrap, allowFallback = false) + assertEquals(DmActions.RelaySource.NONE, resultStrict.source) + + val resultPermissive = DmActions.resolveDmRelays(null, bootstrap = bootstrap, allowFallback = true) + // Null Lists → no kind:10050, no NIP-65 → bootstrap. + assertEquals(DmActions.RelaySource.BOOTSTRAP, resultPermissive.source) + assertEquals(bootstrap, resultPermissive.relays) + } + + // ------------------------------------------------------------------ + // buildTextDm — smoke test that we get back a kind:14 and the right + // wrap count. NIP17Factory internals are exercised more deeply in + // quartz's own tests. + // ------------------------------------------------------------------ + + @Test + fun buildTextDm_producesKind14InnerAndOneWrapPerSide() = + runTest { + val result = DmActions.buildTextDm(signer, recipientPub, "hi from a test") + + assertEquals(ChatMessageEvent.KIND, result.msg.kind) + assertEquals(signer.pubKey, result.msg.pubKey) + assertEquals("hi from a test", result.msg.content) + // NIP17Factory wraps once per recipient — and the sender keeps + // their own copy, so a 1-recipient DM produces 2 wraps. + assertEquals(2, result.wraps.size) + val recipientsCovered = result.wraps.mapNotNull { it.recipientPubKey() }.toSet() + assertTrue(signer.pubKey in recipientsCovered, "sender's own copy missing") + assertTrue(recipientPub in recipientsCovered, "recipient's wrap missing") + } + + @Test + fun relaySourceEnumNamesAreStable() { + // amy emits these as lowercase strings in JSON output; if these + // names change, the public CLI contract breaks. + assertEquals( + setOf("KIND_10050", "NIP65_READ", "BOOTSTRAP", "NONE"), + DmActions.RelaySource.entries + .map { it.name } + .toSet(), + ) + } +} From 95ca12231cf9b1b61ed33bd46e4f0cc162c78ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 00:18:31 +0000 Subject: [PATCH 10/21] feat(amethyst): two more read-only Gemini verbs + signer-prompt plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only surface for the Gemini bridge now covers profiles, notes, and the active account's follow set: * searchNotes(query, limit) — NIP-50 search over kind:1 short text notes via SearchActions.searchNotesFilter + INostrClient.fetchAll. Returns NoteHit list (eventId, npub, content, createdAt). alpha09 of androidx.appfunctions doesn't support List parameters, so no caller-configurable kinds — kind:1 only for now. * getFollowing(limit) — reads account.kind3FollowList.userList.value (already resolved through LocalCache) and projects to FollowedUser with display-name / nip05 / picture from cached kind:0. Reports totalFollowing so the caller knows when limit truncated the list. Both verbs follow the searchProfiles pattern: snapshot active account + client at entry, never re-query sessionManager during the dispatch. Plus amethyst/plans/2026-05-25-appfunctions-signer-prompts.md — design plan for write verbs. Three signers (Internal / Remote / External), three different latency + interaction models. Concrete proposal: Internal first via postNote pilot, Remote as a follow-up, External via PendingIntent (Option A) or NotSupportedException (Option B — recommended for v1) depending on what bundle keys the system shell respects. Open questions enumerated so the experiment day is bounded. --- .../2026-05-25-appfunctions-signer-prompts.md | 239 ++++++++++++++++++ .../appfunctions/AmethystAppFunctions.kt | 149 +++++++++++ 2 files changed, 388 insertions(+) create mode 100644 amethyst/plans/2026-05-25-appfunctions-signer-prompts.md diff --git a/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md b/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md new file mode 100644 index 0000000000..787b30bf26 --- /dev/null +++ b/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md @@ -0,0 +1,239 @@ +# AppFunctions signer prompts — design + +**Date:** 2026-05-25 +**Status:** Draft — no code yet + +How write verbs invoked from background Gemini context (via +`androidx.appfunctions` 1.0.0-alpha09 → `PlatformAppFunctionService`) +acquire a signature from each of Amethyst's three signer types. This +is the gating concern that has us only exposing read-only verbs so far +(`searchProfiles`, `searchNotes`, `getFollowing`). + +## The three signer types and what each needs + +| Signer | Where the private key lives | Sign call latency | Needs user interaction? | +|---|---|---|---| +| **`NostrSignerInternal`** | In-process keypair, loaded at login | Synchronous, microseconds | No | +| **`NostrSignerRemote`** (NIP-46 bunker) | Remote process — a wallet app, browser tab, separate device | Network round-trip via relays, seconds | Yes — the bunker app pops a confirmation on the user's other device | +| **`NostrSignerExternal`** (NIP-55, e.g. Amber) | Another Android app on the same device | Bound-service IPC + activity bounce | Yes — Amber shows an activity in the foreground asking the user to approve | + +Each signer surfaces the same `suspend fun sign(...)` API. The difference is +**what happens to the foreground UI** while the sign is in flight. + +## What App Functions gives us to work with + +From the alpha09 artifact (`androidx.appfunctions:appfunctions-service`): + +- **Suspending dispatch.** `executeFunction` is a suspend function — a slow + signer (NIP-46 round-trip) doesn't block the system shell. +- **Typed exceptions.** `AppFunctionPermissionRequiredException`, + `AppFunctionDeniedException`, `AppFunctionCancelledException`, + `AppFunctionAppException`. The non-default constructors take a `Bundle` — + the system shell can interpret known keys (e.g. a `PendingIntent` to launch + an in-app confirmation). Concrete bundle contract is undocumented in + alpha09; needs a sample-app check or an experiment. +- **`PendingIntent`** is listed as a supported parameter type, which strongly + implies a returned `PendingIntent` can prompt the system to launch the + app's UI for follow-up. +- **No streaming.** Functions return one value or throw. There's no native + "in progress" / "user is approving" signal back to Gemini. + +## Per-signer approach + +### NostrSignerInternal — just works + +Verb runs end-to-end inside the dispatch coroutine. `signer.sign(...)` is +synchronous. Publish via `client.publish(...)`. Return success. + +**Verbs this covers immediately:** post, follow/unfollow, search-relay +list updates, kind:10002 changes — anything where the signed event is +sent and forgotten. + +**Edge case — background `app.client`.** When the app process is +foreground-bound but the user is in Gemini, the client should be +connected. When the user has killed Amethyst recently, the service +process might be cold-started and the client not yet connected to any +relay. The verb needs to either: +- Wait for `client.connect()` (~hundreds of ms once the WebSocket is + established) — acceptable inside the 5-10s window. +- Use `INostrClient.publish(...)` which queues the publish for when the + connection comes up. Quartz needs to confirm this is the actual + behavior; might require `withTimeout` around the publish. + +### NostrSignerRemote — the cleanest async case + +The bunker sends a NIP-46 request to a relay, the bunker app sees it on +the user's other device, the user approves, the signed event comes back. +`signer.sign(...)` suspends until the response arrives or its internal +timeout fires (default 30s). + +**Approach:** call `signer.sign(...)` from within the verb, with a +`withTimeout` budget aligned to App Functions UX expectations (Gemini +typically waits ~30s before showing the user "no response"). On +timeout, throw `AppFunctionCancelledException`. On success, publish and +return. + +**Open question — concurrent foreground signing.** If the user is also +trying to send a post from the foreground UI at the same moment, the +bunker app gets two simultaneous requests. NIP-46 handles this — each +request has a unique id — but Amber-like bunker apps may queue both +prompts confusingly. Worth a manual test. + +### NostrSignerExternal — the hard case + +NIP-55 bounces to a separate Android app's activity. From a background +`PlatformAppFunctionService`, we can't directly `startActivity(...)` — +there's no foreground intent stack to attach to. + +**Two viable approaches:** + +#### Option A — throw a typed exception with a PendingIntent + +```kotlin +@AppFunction +suspend fun postNote(ctx: AppFunctionContext, text: String): PostResult { + val account = activeAccount() ?: throw AppFunctionDeniedException("not signed in") + if (account.signer is NostrSignerExternal) { + // Build a PendingIntent that opens Amethyst at a "approve this + // post" screen, with the draft text passed through extras. + val approvalIntent = buildApprovePostPendingIntent(account, text) + throw AppFunctionPermissionRequiredException( + message = "Amethyst needs to launch the external signer to approve this post.", + extras = bundleOf("pending_intent" to approvalIntent), + ) + } + // … happy path for the in-process signer +} +``` + +The system shell renders "Open Amethyst to continue", user taps, +Amethyst opens, user approves through Amber's activity, post lands. The +Gemini conversation doesn't see the final result — the user has to come +back to Gemini and re-confirm. + +**UX gap.** No way to communicate the eventual outcome back to Gemini's +chat. Acceptable for v1. + +#### Option B — refuse write verbs when the signer is NIP-55 + +Throw `AppFunctionNotSupportedException` immediately. User configures a +different signer (local or NIP-46) to enable Gemini-driven writes. +Simpler, cleaner, but limits the audience — many Amethyst users on +Amber would lose the feature. + +**Recommendation:** start with Option B, ship Internal + Remote support, +then add Option A behind a feature flag in a follow-up. Option B +unblocks the feature for ~70% of users today; Option A is more work +and has the unresolved "result doesn't get back to Gemini" wrinkle. + +## Per-write-verb concerns + +### postNote(text) +- Internal: sign → publish to outbox. Done. +- Remote: sign (suspends) → publish. Done. +- External: throw NotSupported, or PendingIntent dance. +- Side concern: should this go into the user's drafts vs immediately + publish? Gemini-issued posts feel like they should publish (the + user asked for it), but a "review before post" screen via PendingIntent + is a nice safety net even for the local-signer path. + +### follow(npub) / unfollow(npub) +- Same signer paths as postNote, simpler payload. +- Reads the current kind:3, modifies, signs, publishes — `FollowActions` + is ready. +- **No** "preview" step needed — follow/unfollow is reversible. + +### sendDm(recipient, text) +- Same signer paths. +- `DmActions.buildTextDm` is ready, plus `resolveDmRelays`. +- **Concern**: strict mode (default) refuses to send when recipient has + no kind:10050. Should Gemini's `sendDm` default to strict or + permissive? Argument for strict: it's NIP-17 spec behavior. Argument + for permissive: Gemini users won't know what kind:10050 is and will + see confusing failures. **Lean: permissive by default**, surface the + source in the result. + +### zapUser(npub, sats, comment?) +- Same signer paths for the kind:9734 zap request. +- But there's a *second* signing-like step: an LN payment via NWC + (if configured). NWC has its own permission model and can also fail. +- For v1: build the zap request, fetch the BOLT11 invoice, return the + invoice in the result. User pays via their wallet. Skip NWC + auto-payment. + +### zapEvent(eventId, sats, comment?) +- Same as zapUser but uses `ZapActions.buildEventZapRequestsForSplits` + so multi-party notes route correctly. +- May return multiple invoices (one per split recipient). + +## Account selection + +All verbs read `Amethyst.instance.sessionManager.loggedInAccount()` once +at entry. **Multi-account question**: should Gemini be able to specify +*which* account to act as? Two answers: + +- v1: no — always act as the currently-active account. Matches what the + user sees in the foreground UI. Simpler. +- Later: add an optional `accountNpub: String?` parameter to each write + verb. Defaults to the active account. + +Start with v1. + +## Permissions surfaced to Gemini + +The App Functions schema XML (auto-generated by KSP) lists each verb +plus its parameters. Gemini's tool picker shows these to the user. We +should add a `description` (via `isDescribedByKDoc = true`, which we +already do) that makes write verbs sound consequential — "Publishes a +note to your Nostr followers", not "Calls postNote". + +## Open questions for an experiment day + +1. What concrete `Bundle` keys does the system shell respect on + `AppFunctionPermissionRequiredException`? Run a tiny test app, throw + the exception with various bundle contents, observe what Gemini + surfaces. +2. Can the user approve a Gemini-issued write from within the Gemini + chat (inline confirmation) or only by opening Amethyst? Affects + Option A's UX. +3. Does `INostrClient.publish(...)` actually queue when the relay + pool is disconnected, or does it return immediately with no + delivery? Determines whether the verb needs an explicit + "wait for at least one OK" gate. +4. NWC and Gemini: if the user has a NWC wallet configured, should + `zapUser` auto-pay? Adds another consent layer. + +## Minimum viable first write verb + +Pick **`postNote(text)`** as the pilot. + +Why: +- Simplest: one signed event, one publish, one ack. +- Read-back is straightforward: return the event id + the relays it + landed on. Gemini can compose "Posted! Here's the link: nostr:nevent…". +- Failure modes are well-bounded (signer error, no outbox relays, all + relays rejected). +- No multi-party complexity (zap splits, DM strict mode). + +Scope of the pilot: +- `NostrSignerInternal` only (Option B for NIP-55, Remote in a + follow-up). Document the cutoff in kdoc. +- Returns `PostNoteResult(eventId, publishedTo, rejectedBy)` — an + `@AppFunctionSerializable`. +- Builds on the existing `commons/.../quartz/.../TextNoteEvent.build` + and `client.publish` — no new actions needed. +- ~50 lines of new code in `AmethystAppFunctions.kt`, plus the result + class. + +Once shipped, follow-ups in order: +1. `follow(npub)` / `unfollow(npub)` — same signer caveat. +2. NIP-46 (Remote) signer support — change the gate from "signer is + internal" to "signer can sign in-process". +3. `sendDm(recipient, text)` — first write verb that uses encryption. +4. `zapUser` / `zapEvent` — non-trivial because of LN flow. +5. NIP-55 support via PendingIntent (Option A) once the system-shell + contract is understood. + +No write-verb code lands until question (1) above is answered — +otherwise the NIP-55 path is undefined and we ship something that +"sort of works" for half our users. diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index a0d4ccd41f..a3579efcc6 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.actions.SearchActions import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NPub /** @@ -126,6 +127,100 @@ class AmethystAppFunctions { ) } + /** + * Searches Nostr notes for [query] via NIP-50 full-text search across the + * active account's configured search relays (kind:10007), falling back to + * Amethyst's curated default search-relay set when none is configured. + * + * Defaults to short text notes (kind:1) only. Currently no way to widen + * to long-form or other kinds — add a parameter when the need is real; + * App Functions doesn't support `List` parameters in alpha09. + * + * @param query free-form search text. + * @param limit max number of notes to return — capped to 50. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchNotes( + appFunctionContext: AppFunctionContext, + query: String, + limit: Int = 20, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 50) + val filter = SearchActions.searchNotesFilter(query, limit = cappedLimit) ?: return SearchNotesResult.empty() + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + + val relays = account.searchRelayList.flow.value + if (relays.isEmpty()) return SearchNotesResult.empty() + + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + // Sorted newest-first by fetchAll already, but the cast may + // have dropped non-kind:1 events from a relay that ignored + // our kinds filter. + .take(cappedLimit) + .map { ev -> + NoteHit( + eventId = ev.id, + npub = NPub.create(ev.pubKey), + pubkeyHex = ev.pubKey, + createdAt = ev.createdAt, + content = ev.content, + ) + } + + return SearchNotesResult(matches = hits) + } + + /** + * Lists the active account's current follow set — the people the signed-in + * user follows per their latest NIP-02 kind:3 contact list. + * + * Returned entries include best-effort display names sourced from each + * user's cached kind:0; users with no cached metadata appear with + * [FollowedUser.displayName] null. The order matches the on-disk follow + * list (which is the order the user followed them in). + * + * @param limit cap on entries returned — capped to 500. Set to 0 for the + * full list when there's no specific bound. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getFollowing( + appFunctionContext: AppFunctionContext, + limit: Int = 100, + ): FollowingResult { + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return FollowingResult.empty() + + // userList resolves authors through LocalCache so display names / + // pictures / nip05 are filled in for anyone whose kind:0 we've seen. + val users = account.kind3FollowList.userList.value + val effectiveLimit = if (limit <= 0) users.size else limit.coerceIn(1, 500) + + val out = + users + .take(effectiveLimit) + .map { user -> + val meta = user.metadataOrNull() + FollowedUser( + npub = NPub.create(user.pubkeyHex), + pubkeyHex = user.pubkeyHex, + displayName = meta?.bestName(), + nip05 = meta?.nip05(), + picture = meta?.profilePicture(), + ) + } + + return FollowingResult(totalFollowing = users.size, returned = out) + } + companion object { /** * 6-second fetch window. App Functions invocations are user-initiated @@ -167,3 +262,57 @@ class SearchProfilesResult( fun empty() = SearchProfilesResult(matches = emptyList()) } } + +/** Single match in [SearchNotesResult]. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class NoteHit( + /** Hex event id of the note. */ + val eventId: String, + /** Bech32 npub of the note's author. */ + val npub: String, + /** Hex pubkey of the note's author. */ + val pubkeyHex: String, + /** Unix-seconds timestamp the note was created at. */ + val createdAt: Long, + /** Raw content of the note (plain text, may contain Nostr URIs / hashtags). */ + val content: String, +) + +@AppFunctionSerializable(isDescribedByKDoc = true) +class SearchNotesResult( + /** Matched notes, sorted newest-first by created_at. */ + val matches: List, +) { + companion object { + fun empty() = SearchNotesResult(matches = emptyList()) + } +} + +/** Single entry in [FollowingResult]. Metadata fields may be null when the + * user's kind:0 hasn't been cached locally. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class FollowedUser( + /** Bech32 npub of the followed user. */ + val npub: String, + /** Hex pubkey of the followed user. */ + val pubkeyHex: String, + /** Best-effort display name (display_name then name). */ + val displayName: String?, + /** NIP-05 verified handle, e.g. `alice@example.com`. */ + val nip05: String?, + /** Avatar image URL. */ + val picture: String?, +) + +@AppFunctionSerializable(isDescribedByKDoc = true) +class FollowingResult( + /** Total number of follows in the active account's kind:3 — may exceed + * [returned] when the caller passed a limit. */ + val totalFollowing: Int, + /** Subset of follows returned to the caller, in original on-disk order. */ + val returned: List, +) { + companion object { + fun empty() = FollowingResult(totalFollowing = 0, returned = emptyList()) + } +} From 82188b719ad24ac3281ba7c6946381913d4c9f43 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 21:50:56 +0000 Subject: [PATCH 11/21] fix(amethyst): generate app_functions.xml so the system can resolve metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The androidx.appfunctions-compiler runs in per-module mode by default, emitting only the dispatcher Kotlin code. The aggregator that builds the `app_functions.xml` + `app_functions_v2.xml` assets is gated behind a KSP argument that was off. Symptom on a Pixel 8 running our APK: D AppFunctions: Unable to resolve AppFunctionMetadata. Without the aggregated asset, the manifest's `android.app.appfunctions` property pointed at a file that didn't exist; the System UI couldn't enumerate our @AppFunction methods so Gemini's tool picker never saw them. Setting `appfunctions:aggregateAppFunctions = "true"` on the amethyst module turns the aggregator on. Verified post-build: assets/app_functions.xml (688 bytes — manifest pointer + ids) assets/app_functions_v2.xml (19.7 KB — full schemas + kdoc descriptions) Both list searchProfiles / searchNotes / getFollowing with the kdoc descriptions Gemini will render. Library modules (commons/quartz) would set this to "false" — only the final app emits the aggregate. We don't currently apply the KSP plugin in any library module, so this is the only place that matters. --- amethyst/build.gradle.kts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index be6e7894bc..1a0a0471c6 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -272,6 +272,18 @@ android { } } +// androidx.appfunctions-compiler runs in a per-module mode by default, +// emitting only the dispatcher Kotlin code. The aggregator that builds +// the `app_functions.xml` asset (which the system reads to discover our +// @AppFunction methods) is gated behind this KSP argument — without it, +// the manifest's `android.app.appfunctions` property points at a file +// that doesn't exist and the System UI logs "Unable to resolve +// AppFunctionMetadata." Set on the app module only; library modules +// (commons/quartz) would set it to "false". +ksp { + arg("appfunctions:aggregateAppFunctions", "true") +} + // TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5 configurations.all { val tink = "com.google.crypto.tink:tink-android:1.17.0" From 0619788644ea13d32debc49a98847d1dc4835ea1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 22:03:46 +0000 Subject: [PATCH 12/21] fix(amethyst): supply app_metadata so AppFunctions discovery actually works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After fixing the missing aggregated XML, Pixel 8 logcat still showed: D AppFunctions: Unable to resolve AppFunctionMetadata. Comparing against Google's FilipFan/AppFunctionsPilot sample turned up a separate metadata pointer the system requires: This goes on the element (not the service) and points to an XML resource — distinct from the asset-side `app_functions.xml` that the library auto-merges onto the service. The asset metadata declares "here are my function ids and schemas"; the resource metadata gives the agent a user-facing summary like "Search Nostr and read your follows" to show users before they grant access. Without the resource, the system can find our service and our function list but can't resolve the descriptive metadata it shows the user — so Gemini's tool picker stays empty. Two new files: * amethyst/src/play/res/xml/app_metadata.xml — short description + displayDescription. Update when the @AppFunction surface grows. * play AndroidManifest pointing at the resource. Also dropped our explicit declaration for PlatformAppFunctionService — confirmed via the appfunctions-service AAR that the library auto-merges that exact entry, complete with permission + intent-filter, so our copy was redundant. --- amethyst/src/play/AndroidManifest.xml | 27 +++++++++++----------- amethyst/src/play/res/xml/app_metadata.xml | 16 +++++++++++++ 2 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 amethyst/src/play/res/xml/app_metadata.xml diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index 044e757915..60c2dfb7a1 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -40,21 +40,20 @@ - - - - - + com.vitorpamplona.amethyst.appfunctions.AmethystAppFunctions). + Play flavor only — the F-Droid channel ships without the + alpha Google AI dependency. + + The `app_metadata` property below gives the system agent a + user-facing summary of what this app exposes. Without it, + system logcat logs "Unable to resolve AppFunctionMetadata" + and our functions never make it into Gemini's tool picker. --> + diff --git a/amethyst/src/play/res/xml/app_metadata.xml b/amethyst/src/play/res/xml/app_metadata.xml new file mode 100644 index 0000000000..e8cd609827 --- /dev/null +++ b/amethyst/src/play/res/xml/app_metadata.xml @@ -0,0 +1,16 @@ + + + From 5c46d0a7b39988781cb4ff9377a83b4df330b5e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 22:58:30 +0000 Subject: [PATCH 13/21] =?UTF-8?q?feat(amethyst):=20five=20more=20read-only?= =?UTF-8?q?=20Gemini=20verbs=20=E2=80=94=20full=20Tier=201=20read=20surfac?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the on-device round-trip proved the AppFunctions plumbing works, adding the verbs that make Gemini actually useful for a Nostr user. All read-only, no signer interaction, all build on existing actions / Account state. * getRecentFromFollows(limit) — "what's happening on Nostr today?" Drains recent kind:1 from people the user follows; same relay set the home-feed UI uses (account.homeRelays). * getNotesByUser(user, limit) — "what did Vitor post recently?" Accepts npub or 64-hex. Prefers the target's NIP-65 write relays when cached, falls back to the active account's home relays. * getProfile(user) — "who is npub1xq5...?". Cache-first via LocalCache; falls back to a short network drain for unseen users. Returns GetProfileResult{found, profile} so callers know whether the user just isn't in cache or doesn't have a kind:0 yet. * searchByHashtag(hashtag, limit) — "find Nostr posts about Bitcoin". NIP-12 `t` tag filter, lowercased to match the client convention. * getActiveAccountInfo() — "who am I logged in as?" Diagnostic verb returning npub, display name, follow count, outbox + DM relay counts. Distinguishes signed-in from signed-out via a flag rather than a magic empty result. Plus: * decodeUserOrThrow helper for npub/hex parsing, throws AppFunctionInvalidArgumentException with a typed message so callers see "expected npub1… or 64-char hex" instead of a stack. * TextNoteEvent.toNoteHit helper — extracted from the existing searchNotes path to avoid duplication. * Updated res/xml/app_metadata.xml description so Gemini's tool picker can pitch a broader summary to the user. KSP-verified: $AmethystAppFunctions_AppFunctionInvoker now dispatches all eight verbs (the three from the previous commits plus these five). --- .../appfunctions/AmethystAppFunctions.kt | 353 +++++++++++++++++- amethyst/src/play/res/xml/app_metadata.xml | 4 +- 2 files changed, 346 insertions(+), 11 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index a3579efcc6..8db27369af 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -21,13 +21,19 @@ package com.vitorpamplona.amethyst.appfunctions import androidx.appfunctions.AppFunctionContext +import androidx.appfunctions.AppFunctionInvalidArgumentException import androidx.appfunctions.AppFunctionSerializable import androidx.appfunctions.service.AppFunction import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.actions.SearchActions +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.nip19Bech32.entities.NPub /** @@ -114,6 +120,272 @@ class AmethystAppFunctions { return SearchProfilesResult(matches = hits) } + /** + * Drains recent kind:1 short text notes from the people the active + * account follows. The "what's new on Nostr" verb — same query the + * Amethyst home-feed UI runs, just truncated to one batch. + * + * Queried relays: the active account's home relays (NIP-65 outbox + + * any private storage + local relays). Same source the UI uses, so + * Gemini sees what the user would see on their timeline. + * + * @param limit max notes to return, capped to 200. Default 30. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getRecentFromFollows( + appFunctionContext: AppFunctionContext, + limit: Int = 30, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 200) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + + val authors = account.kind3FollowList.flow.value.authors + if (authors.isEmpty()) return SearchNotesResult.empty() + + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = authors.toList(), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Recent kind:1 short text notes from a specific user. Use this when + * the user asks "what did Vitor post recently?" or "catch me up on + * Snowden" — pass that user's npub or 64-hex pubkey. + * + * Queried relays: prefer the target's NIP-65 write relays (where they + * publish) when their kind:10002 is cached locally; fall back to the + * active account's home relays. + * + * @param user npub (`npub1…`) or 64-character hex pubkey. + * @param limit max notes to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getNotesByUser( + appFunctionContext: AppFunctionContext, + user: String, + limit: Int = 20, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 100) + val pubkey = decodeUserOrThrow(user) + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + + val targetWriteRelays = + account.cache + .checkGetOrCreateUser(pubkey) + ?.outboxRelays() + ?.toSet() + .orEmpty() + val relays = + targetWriteRelays + .ifEmpty { account.homeRelays.flow.value } + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = listOf(pubkey), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .filter { it.pubKey == pubkey } + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Looks up one Nostr profile by [user] (npub or 64-hex). Returns the + * latest kind:0 metadata available — checks the local cache first, + * falls back to a short network drain. + * + * @param user npub (`npub1…`) or 64-character hex pubkey. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getProfile( + appFunctionContext: AppFunctionContext, + user: String, + ): GetProfileResult { + val pubkey = decodeUserOrThrow(user) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return GetProfileResult.notFound(pubkey) + + // Cache-first: the foreground UI keeps observed metadata around. + val cached = + account.cache + .checkGetOrCreateUser(pubkey) + ?.metadataOrNull() + if (cached != null) { + return GetProfileResult( + found = true, + profile = + ProfileHit( + npub = NPub.create(pubkey), + pubkeyHex = pubkey, + displayName = cached.bestName(), + about = null, + nip05 = cached.nip05(), + picture = cached.profilePicture(), + lnAddress = cached.lnAddress(), + ), + ) + } + + // Cache miss: drain bootstrap relays for the latest kind:0. + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return GetProfileResult.notFound(pubkey) + + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(pubkey), + limit = 1, + ) + val event = + client + .fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ).mapNotNull { it as? MetadataEvent } + .filter { it.pubKey == pubkey } + .maxByOrNull { it.createdAt } + ?: return GetProfileResult.notFound(pubkey) + + return GetProfileResult(found = true, profile = event.toProfileHit()) + } + + /** + * Find kind:1 short text notes tagged with a hashtag (NIP-12 `t` + * tag). For "find me Nostr posts about Bitcoin" — pass `bitcoin`, + * not `#bitcoin`. The hashtag is lowercased before matching, which + * is the convention most Nostr clients (Amethyst included) follow. + * + * @param hashtag the tag value without the leading `#`. + * @param limit max notes to return, capped to 100. Default 30. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchByHashtag( + appFunctionContext: AppFunctionContext, + hashtag: String, + limit: Int = 30, + ): SearchNotesResult { + val tag = hashtag.trim().removePrefix("#").lowercase() + if (tag.isEmpty()) throw AppFunctionInvalidArgumentException("hashtag must not be blank") + val cappedLimit = limit.coerceIn(1, 100) + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("t" to listOf(tag)), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Returns who the user is currently signed in as on Nostr — their + * npub, best-effort display name, follow count, and how many relays + * are configured for outbox / inbox. Diagnostic verb for queries + * like "who am I logged in as?" or "what's my npub?". + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getActiveAccountInfo(appFunctionContext: AppFunctionContext): AccountInfoResult { + val account = + Amethyst.instance.sessionManager.loggedInAccount() ?: return AccountInfoResult.signedOut() + + val myPub = account.signer.pubKey + val myUser = account.cache.checkGetOrCreateUser(myPub) + val meta = myUser?.metadataOrNull() + + return AccountInfoResult( + signedIn = true, + npub = NPub.create(myPub), + pubkeyHex = myPub, + displayName = meta?.bestName(), + nip05 = meta?.nip05(), + followCount = account.kind3FollowList.userList.value.size, + outboxRelayCount = account.homeRelays.flow.value.size, + dmRelayCount = account.dmRelays.flow.value.size, + ) + } + + /** + * Accepts either an npub bech32 (`npub1…`) or 64-character hex + * pubkey and returns the 64-char lowercase hex. Throws + * [AppFunctionInvalidArgumentException] on anything else so the + * caller sees a typed error rather than a generic crash. + */ + private fun decodeUserOrThrow(input: String): HexKey = + runCatching { decodePublicKey(input.trim()).toHexKey() } + .getOrElse { + throw AppFunctionInvalidArgumentException( + "Could not decode user '$input' — expected npub1… or 64-char hex pubkey.", + ) + } + + private fun TextNoteEvent.toNoteHit(): NoteHit = + NoteHit( + eventId = id, + npub = NPub.create(pubKey), + pubkeyHex = pubKey, + createdAt = createdAt, + content = content, + ) + private fun MetadataEvent.toProfileHit(): ProfileHit { val meta = contactMetaData() return ProfileHit( @@ -167,15 +439,7 @@ class AmethystAppFunctions { // have dropped non-kind:1 events from a relay that ignored // our kinds filter. .take(cappedLimit) - .map { ev -> - NoteHit( - eventId = ev.id, - npub = NPub.create(ev.pubKey), - pubkeyHex = ev.pubKey, - createdAt = ev.createdAt, - content = ev.content, - ) - } + .map { it.toNoteHit() } return SearchNotesResult(matches = hits) } @@ -316,3 +580,74 @@ class FollowingResult( fun empty() = FollowingResult(totalFollowing = 0, returned = emptyList()) } } + +/** + * Result of [AmethystAppFunctions.getProfile]. Distinguishes "user has no + * cached + observable kind:0 metadata" (`found = false`) from "user has a + * stub profile with empty fields" — the latter shouldn't normally happen + * but the explicit flag keeps callers from rendering a hollow card. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class GetProfileResult( + /** True when a kind:0 was found (cache or relay). False means the + * user exists as a pubkey but no profile event was reachable. */ + val found: Boolean, + /** Resolved profile when [found] is true, otherwise a stub with + * pubkey-only fields populated. */ + val profile: ProfileHit?, +) { + companion object { + fun notFound(pubkeyHex: String) = + GetProfileResult( + found = false, + profile = + ProfileHit( + npub = NPub.create(pubkeyHex), + pubkeyHex = pubkeyHex, + displayName = null, + about = null, + nip05 = null, + picture = null, + lnAddress = null, + ), + ) + } +} + +/** + * Summary of the active Nostr account on this device. Returned by + * [AmethystAppFunctions.getActiveAccountInfo]. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class AccountInfoResult( + /** False when no account is currently logged into Amethyst. */ + val signedIn: Boolean, + /** Bech32 npub of the active account, or null when signed out. */ + val npub: String?, + /** Hex pubkey of the active account, or null when signed out. */ + val pubkeyHex: String?, + /** Best-effort display name from cached kind:0. */ + val displayName: String?, + /** NIP-05 verified handle. */ + val nip05: String?, + /** Number of pubkeys in the user's current kind:3 follow list. */ + val followCount: Int, + /** Number of NIP-65 outbox / home relays configured. */ + val outboxRelayCount: Int, + /** Number of NIP-17 DM-inbox relays (kind:10050) configured. */ + val dmRelayCount: Int, +) { + companion object { + fun signedOut() = + AccountInfoResult( + signedIn = false, + npub = null, + pubkeyHex = null, + displayName = null, + nip05 = null, + followCount = 0, + outboxRelayCount = 0, + dmRelayCount = 0, + ) + } +} diff --git a/amethyst/src/play/res/xml/app_metadata.xml b/amethyst/src/play/res/xml/app_metadata.xml index e8cd609827..13acc95847 100644 --- a/amethyst/src/play/res/xml/app_metadata.xml +++ b/amethyst/src/play/res/xml/app_metadata.xml @@ -12,5 +12,5 @@ the @AppFunction surface grows. --> + appfn:description="Amethyst is a Nostr social client. The agent can search Nostr profiles, notes, and hashtags through Amethyst's configured search relays; look up any user's profile by npub; read recent posts from the signed-in user's follows or from a specific user; and report basic account info (npub, display name, follow count)." + appfn:displayDescription="Search and read Nostr through Amethyst" /> From c10ed49631df368febe3d530a9fb202337030da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 23:06:47 +0000 Subject: [PATCH 14/21] =?UTF-8?q?feat(amethyst):=20Tier=202+3=20read-only?= =?UTF-8?q?=20Gemini=20verbs=20=E2=80=94=2015=20total?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now exposing the full read-only Nostr surface to Gemini. Seven new @AppFunction methods on top of the previous eight: * getMyRecentNotes(limit) — author=me filter on kind:1. * getMyMentions(limit) — p-tag=me filter on kind:1. "Did anyone @ me?". * getRepliesToNote(eventId, limit) — e-tag=eventId filter on kind:1. Pair with getMyRecentNotes(1) for "did anyone respond to my last post?". * getZapsReceived(hoursBack) — drains kind:9735 receipts addressed to the user in the window, parses the bolt11 invoice from each, sums sats. Returns total + zap count + unique zappers + count of receipts whose bolt11 was unparseable. * getRecentDms(peer?, hoursBack, limit) — NIP-17 gift-wrap drain + unwrapAndUnsealOrNull decrypt. kind:14 text DMs only for v1 (skip kind:15 encrypted-file headers to keep payloads bounded). Widens the `since` filter by 2 days for NIP-59's randomised-past created_at trick, then trims back to the requested window. * searchArticles(query, limit) — same as searchNotes but kind:30023 long-form articles. Content snippet truncated at 2000 chars so a book-length article doesn't blow up the AppFunctions response; Gemini can ask the user whether to fetch the full article via a different verb. * getLiveStreams(limit) — NIP-53 kind:30311 with status=live (uses quartz's 8-hour staleness guard via LiveActivitiesEvent.isLive). Returns title, summary, host npub, start time, event id. Plus updated res/xml/app_metadata.xml so Gemini's tool picker pitches the full surface to users. KSP-verified — 15 verbs total in the generated dispatcher: getActiveAccountInfo getFollowing getLiveStreams getMyMentions getMyRecentNotes getNotesByUser getProfile getRecentDms getRecentFromFollows getRepliesToNote getZapsReceived searchArticles searchByHashtag searchNotes searchProfiles Write verbs (post, follow, zap, sendDm) still deferred behind the signer-prompt plan in amethyst/plans/2026-05-25-appfunctions-signer-prompts.md — no behavior change there. --- .../appfunctions/AmethystAppFunctions.kt | 508 ++++++++++++++++++ amethyst/src/play/res/xml/app_metadata.xml | 4 +- 2 files changed, 510 insertions(+), 2 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 8db27369af..0271d98447 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -27,14 +27,23 @@ import androidx.appfunctions.service.AppFunction import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.actions.SearchActions import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils /** * Bridge that exposes Amethyst's "verbs" (commons/.../actions/) to the Android @@ -363,6 +372,413 @@ class AmethystAppFunctions { ) } + /** + * Most recent kind:1 notes the active account itself published. For + * "what did I post recently?" — drains the user's own outbox relays + * filtered to their own pubkey. + * + * @param limit max notes to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getMyRecentNotes( + appFunctionContext: AppFunctionContext, + limit: Int = 20, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 100) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val myPub = account.signer.pubKey + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = listOf(myPub), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .filter { it.pubKey == myPub } + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Notes where someone tagged the active account with a `p` tag — + * the Nostr equivalent of being @-mentioned. Use this for "did + * anyone mention me recently?". + * + * @param limit max notes to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getMyMentions( + appFunctionContext: AppFunctionContext, + limit: Int = 20, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 100) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val myPub = account.signer.pubKey + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("p" to listOf(myPub)), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Replies to a specific note (kind:1 events with an `e` tag + * pointing at [eventId]). Used for "did anyone respond to my last + * post?" — pass `getMyRecentNotes(1).matches.first().eventId` + * from a previous call, or any other note you want to track. + * + * @param eventId 64-character hex id of the note being replied to. + * @param limit max replies to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getRepliesToNote( + appFunctionContext: AppFunctionContext, + eventId: String, + limit: Int = 20, + ): SearchNotesResult { + if (eventId.length != 64) { + throw AppFunctionInvalidArgumentException("eventId must be 64-character hex (nevent bech32 not yet supported)") + } + val cappedLimit = limit.coerceIn(1, 100) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return SearchNotesResult.empty() + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + tags = mapOf("e" to listOf(eventId)), + limit = cappedLimit, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? TextNoteEvent } + .filter { it.id != eventId } // self-reference safety + .take(cappedLimit) + .map { it.toNoteHit() } + + return SearchNotesResult(matches = hits) + } + + /** + * Total sats received as NIP-57 zaps in the last [hoursBack] hours, + * plus a count of distinct zappers. For "did I earn any sats + * today?" — defaults to 24 hours. + * + * Each kind:9735 receipt carries a `bolt11` invoice; we parse the + * amount out and sum them. Receipts without a parseable amount are + * counted but contribute 0 sats. + * + * @param hoursBack window size in hours. Capped to 168 (7 days), + * default 24. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getZapsReceived( + appFunctionContext: AppFunctionContext, + hoursBack: Int = 24, + ): ZapsReceivedResult { + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return ZapsReceivedResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return ZapsReceivedResult.empty() + + val cappedHours = hoursBack.coerceIn(1, 24 * 7) + val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L + val myPub = account.signer.pubKey + + val filter = + Filter( + kinds = listOf(LnZapEvent.KIND), + tags = mapOf("p" to listOf(myPub)), + since = sinceSecs, + limit = 500, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val receipts = events.mapNotNull { it as? LnZapEvent } + val zapperIds = mutableSetOf() + var totalSats = 0L + var unparseable = 0 + for (z in receipts) { + val bolt11 = + z.tags + .firstOrNull { it.size > 1 && it[0] == "bolt11" } + ?.get(1) + val sats = + bolt11 + ?.let { runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull() } + ?: run { + unparseable++ + 0L + } + totalSats += sats + // Zap sender is recorded in the description's signed kind:9734; + // we only have it as a pubkey-id mention via `P` tag on some + // receipts. Best-effort: + z.tags + .firstOrNull { it.size > 1 && (it[0] == "P" || it[0] == "p" && it[1] != myPub) } + ?.get(1) + ?.let { zapperIds.add(it) } + } + + return ZapsReceivedResult( + windowHours = cappedHours, + totalSats = totalSats, + zapCount = receipts.size, + uniqueZapperCount = zapperIds.size, + unparseableInvoiceCount = unparseable, + ) + } + + /** + * Recent direct messages addressed to the active account. Drains + * NIP-17 gift wraps from inbox relays, decrypts each, and returns + * the inner kind:14 messages. + * + * @param peer optional npub/hex; when set, only returns messages + * from that specific peer. When null, returns from anyone. + * @param hoursBack window size in hours. Capped to 168 (7 days), + * default 24. + * @param limit max messages to return, capped to 100. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getRecentDms( + appFunctionContext: AppFunctionContext, + peer: String?, + hoursBack: Int = 24, + limit: Int = 20, + ): DmsResult { + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return DmsResult.empty() + val client = Amethyst.instance.client + val cappedHours = hoursBack.coerceIn(1, 24 * 7) + val cappedLimit = limit.coerceIn(1, 100) + val peerPub = peer?.takeIf { it.isNotBlank() }?.let { decodeUserOrThrow(it) } + + // DM-inbox relays per kind:10050; fall back to home relays if the + // user never published a kind:10050 (interop with stale clients). + val relays = + account.dmRelays.flow.value + .ifEmpty { account.homeRelays.flow.value } + if (relays.isEmpty()) return DmsResult.empty() + + val myPub = account.signer.pubKey + val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L + + // NIP-59 gift wraps randomise their `created_at` up to two days + // in the past, so we widen the filter by 2 days. Same trick the + // foreground client and amy use. + val filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(myPub)), + since = sinceSecs - TimeUtils.twoDays(), + limit = 200, + ) + val wraps = + client + .fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ).mapNotNull { it as? GiftWrapEvent } + + val seen = HashSet() + val messages = mutableListOf() + for (wrap in wraps) { + val inner = wrap.unwrapAndUnsealOrNull(account.signer) ?: continue + if (inner !is BaseDMGroupEvent) continue + if (inner !is ChatMessageEvent) continue // skip file headers for v1; keep payload small + if (!seen.add(inner.id)) continue + // After widening for randomised `created_at`, drop anything + // outside the requested window so the result honours the + // caller's hoursBack. + if (inner.createdAt < sinceSecs) continue + if (peerPub != null && peerPub !in inner.groupMembers()) continue + + messages.add( + DmMessage( + fromNpub = NPub.create(inner.pubKey), + fromPubkeyHex = inner.pubKey, + content = inner.content, + createdAt = inner.createdAt, + ), + ) + } + + return DmsResult( + windowHours = cappedHours, + messages = + messages + .sortedByDescending { it.createdAt } + .take(cappedLimit), + ) + } + + /** + * NIP-50 search restricted to NIP-23 long-form articles + * (kind:30023). Use for "find Nostr articles about [topic]" when + * the user wants written-up posts rather than short notes. + * + * @param query free-form search text. + * @param limit max articles to return, capped to 50. Default 10. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchArticles( + appFunctionContext: AppFunctionContext, + query: String, + limit: Int = 10, + ): SearchNotesResult { + val cappedLimit = limit.coerceIn(1, 50) + val filter = + SearchActions.searchNotesFilter( + query = query, + kinds = listOf(LongTextNoteEvent.KIND), + limit = cappedLimit, + ) ?: return SearchNotesResult.empty() + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() + val client = Amethyst.instance.client + val relays = account.searchRelayList.flow.value + if (relays.isEmpty()) return SearchNotesResult.empty() + + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val hits = + events + .mapNotNull { it as? LongTextNoteEvent } + .take(cappedLimit) + .map { ev -> + // Long-form articles can be book-length; cap the + // content payload so the AppFunctions result stays + // bounded — Gemini can ask for a follow-up if needed. + val snippet = + if (ev.content.length > LONG_FORM_SNIPPET_LIMIT) { + ev.content.take(LONG_FORM_SNIPPET_LIMIT) + "…" + } else { + ev.content + } + NoteHit( + eventId = ev.id, + npub = NPub.create(ev.pubKey), + pubkeyHex = ev.pubKey, + createdAt = ev.createdAt, + content = snippet, + ) + } + + return SearchNotesResult(matches = hits) + } + + /** + * Live audio/video streams currently broadcasting on Nostr (NIP-53 + * kind:30311 events with `status=live`). Use for "what's live on + * Nostr right now?". + * + * @param limit max streams to return, capped to 50. Default 20. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getLiveStreams( + appFunctionContext: AppFunctionContext, + limit: Int = 20, + ): LiveStreamsResult { + val cappedLimit = limit.coerceIn(1, 50) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return LiveStreamsResult.empty() + val client = Amethyst.instance.client + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return LiveStreamsResult.empty() + + // NIP-53 has no `since` semantics — a live activity can have an + // arbitrarily old createdAt. We over-fetch and post-filter for + // `isLive()`, which also applies the 8-hour staleness guard + // (status=live + recent createdAt) baked into quartz. + val filter = + Filter( + kinds = listOf(LiveActivitiesEvent.KIND), + limit = cappedLimit * 4, + ) + val events = + client.fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ) + + val streams = + events + .mapNotNull { it as? LiveActivitiesEvent } + .filter { it.isLive() } + .take(cappedLimit) + .map { ev -> + val hostPub = ev.host()?.pubKey + LiveStreamHit( + eventId = ev.id, + title = ev.title(), + summary = ev.summary(), + hostNpub = hostPub?.let { NPub.create(it) }, + hostPubkeyHex = hostPub, + startsAt = ev.starts(), + createdAt = ev.createdAt, + ) + } + + return LiveStreamsResult(streams = streams) + } + /** * Accepts either an npub bech32 (`npub1…`) or 64-character hex * pubkey and returns the 64-char lowercase hex. Throws @@ -492,6 +908,14 @@ class AmethystAppFunctions { * is a poor user experience. */ private const val GEMINI_FETCH_TIMEOUT_MS = 6_000L + + /** + * Cap on the content payload returned from [searchArticles] — NIP-23 + * articles can be book-length; truncate so the AppFunctions response + * stays bounded. Gemini can show the snippet and ask the user + * whether to fetch the full article. + */ + private const val LONG_FORM_SNIPPET_LIMIT = 2_000 } } @@ -614,6 +1038,90 @@ class GetProfileResult( } } +/** + * Aggregate of NIP-57 zaps received in a recent time window. Returned + * by [AmethystAppFunctions.getZapsReceived]. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ZapsReceivedResult( + /** Window size in hours that was actually queried (after capping). */ + val windowHours: Int, + /** Sum of sats from every parseable bolt11 invoice in the window. */ + val totalSats: Long, + /** Total kind:9735 receipts observed — includes ones with unparseable invoices. */ + val zapCount: Int, + /** Distinct zapping pubkeys, best-effort from the `P` / second-`p` tag. */ + val uniqueZapperCount: Int, + /** Receipts whose bolt11 couldn't be parsed and didn't contribute to [totalSats]. */ + val unparseableInvoiceCount: Int, +) { + companion object { + fun empty() = + ZapsReceivedResult( + windowHours = 0, + totalSats = 0L, + zapCount = 0, + uniqueZapperCount = 0, + unparseableInvoiceCount = 0, + ) + } +} + +/** One decrypted NIP-17 direct message returned by [AmethystAppFunctions.getRecentDms]. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class DmMessage( + /** Bech32 npub of the sender. */ + val fromNpub: String, + /** Hex pubkey of the sender. */ + val fromPubkeyHex: String, + /** Plaintext message body. */ + val content: String, + /** Unix-seconds timestamp of the inner kind:14 event. */ + val createdAt: Long, +) + +/** Decrypted recent NIP-17 DMs in a time window. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class DmsResult( + /** Window size in hours that was actually queried. */ + val windowHours: Int, + /** Messages, newest first. Capped to the caller's limit. */ + val messages: List, +) { + companion object { + fun empty() = DmsResult(windowHours = 0, messages = emptyList()) + } +} + +/** Single hit from [AmethystAppFunctions.getLiveStreams]. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class LiveStreamHit( + /** Hex event id of the kind:30311 announcement. */ + val eventId: String, + /** Stream title from the `title` tag, or null when absent. */ + val title: String?, + /** Short description from the `summary` tag, or null when absent. */ + val summary: String?, + /** Bech32 npub of the host, when a host tag is present. */ + val hostNpub: String?, + /** Hex pubkey of the host, when a host tag is present. */ + val hostPubkeyHex: String?, + /** Unix-seconds timestamp the stream's `starts` tag points to. */ + val startsAt: Long?, + /** Unix-seconds timestamp of the kind:30311 event itself. */ + val createdAt: Long, +) + +@AppFunctionSerializable(isDescribedByKDoc = true) +class LiveStreamsResult( + /** Currently-live streams, in the order they were observed. */ + val streams: List, +) { + companion object { + fun empty() = LiveStreamsResult(streams = emptyList()) + } +} + /** * Summary of the active Nostr account on this device. Returned by * [AmethystAppFunctions.getActiveAccountInfo]. diff --git a/amethyst/src/play/res/xml/app_metadata.xml b/amethyst/src/play/res/xml/app_metadata.xml index 13acc95847..6bfe897850 100644 --- a/amethyst/src/play/res/xml/app_metadata.xml +++ b/amethyst/src/play/res/xml/app_metadata.xml @@ -12,5 +12,5 @@ the @AppFunction surface grows. --> + appfn:description="Amethyst is a Nostr social client. The agent can: search Nostr profiles, notes, hashtags, and long-form articles; look up any user's profile by npub; read recent posts from the signed-in user's follows or any specific user; read recent direct messages (decrypted); list NIP-57 zaps received and total sats earned in a time window; surface notes that mention or reply to the user; list currently-live audio/video streams; and report basic account info (npub, display name, follow count)." + appfn:displayDescription="Read Nostr through Amethyst — feed, DMs, profiles, zaps" /> From ac105ca2f3f4f14572ed21cc8160085afa325627 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 23:11:10 +0000 Subject: [PATCH 15/21] chore(amethyst): enrich AppFunction outputs so Gemini can name names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every verb that returned a pubkey now also returns the best-effort display name from the local kind:0 cache. Before this commit Gemini could only say "you got a DM from npub1abc…" — now it can say "you got a DM from Alice" because the LLM has the field at hand instead of having to chain another lookup. * NoteHit gains authorDisplayName (cache-resolved, null when the author's kind:0 isn't local yet). Applied to every verb that returns notes: searchNotes / getRecentFromFollows / getNotesByUser / searchByHashtag / getMyRecentNotes / getMyMentions / getRepliesToNote / searchArticles. * DmMessage gains fromDisplayName + sentByMe — the latter lets the caller distinguish "Alice said X" from "I said Y" when both appear in the same thread snapshot. * LiveStreamHit gains streamingUrl (was missing entirely — without it the verb is useless, you can't watch a stream you can't open) plus hostDisplayName. * getProfile cache-hit path now actually populates `about` — was silently null before because the early-return branch didn't read it out of UserInfo. Cache-miss path was always correct. Implementation: one `displayNameOf(HexKey): String?` helper reads from Amethyst.instance.cache (LocalCache) — the same cache the foreground UI uses. Zero allocations beyond the lookup, no network round-trip. --- .../appfunctions/AmethystAppFunctions.kt | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 0271d98447..5aefa53134 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -257,6 +257,7 @@ class AmethystAppFunctions { .checkGetOrCreateUser(pubkey) ?.metadataOrNull() if (cached != null) { + val info = cached.flow.value?.info return GetProfileResult( found = true, profile = @@ -264,7 +265,7 @@ class AmethystAppFunctions { npub = NPub.create(pubkey), pubkeyHex = pubkey, displayName = cached.bestName(), - about = null, + about = info?.about, nip05 = cached.nip05(), picture = cached.profilePicture(), lnAddress = cached.lnAddress(), @@ -649,6 +650,8 @@ class AmethystAppFunctions { DmMessage( fromNpub = NPub.create(inner.pubKey), fromPubkeyHex = inner.pubKey, + fromDisplayName = displayNameOf(inner.pubKey), + sentByMe = inner.pubKey == myPub, content = inner.content, createdAt = inner.createdAt, ), @@ -715,6 +718,7 @@ class AmethystAppFunctions { eventId = ev.id, npub = NPub.create(ev.pubKey), pubkeyHex = ev.pubKey, + authorDisplayName = displayNameOf(ev.pubKey), createdAt = ev.createdAt, content = snippet, ) @@ -769,8 +773,10 @@ class AmethystAppFunctions { eventId = ev.id, title = ev.title(), summary = ev.summary(), + streamingUrl = ev.streaming(), hostNpub = hostPub?.let { NPub.create(it) }, hostPubkeyHex = hostPub, + hostDisplayName = hostPub?.let { displayNameOf(it) }, startsAt = ev.starts(), createdAt = ev.createdAt, ) @@ -793,11 +799,26 @@ class AmethystAppFunctions { ) } + /** + * Look up the cached display name for a pubkey. Returns null when no + * kind:0 has been observed for this user yet — caller renders the + * npub instead. + * + * Cheap in-memory read against the same LocalCache the foreground UI + * uses; no relay round-trip, no allocation beyond the lookup. + */ + private fun displayNameOf(pubkey: HexKey): String? = + Amethyst.instance.cache + .checkGetOrCreateUser(pubkey) + ?.metadataOrNull() + ?.bestName() + private fun TextNoteEvent.toNoteHit(): NoteHit = NoteHit( eventId = id, npub = NPub.create(pubKey), pubkeyHex = pubKey, + authorDisplayName = displayNameOf(pubKey), createdAt = createdAt, content = content, ) @@ -960,6 +981,10 @@ class NoteHit( val npub: String, /** Hex pubkey of the note's author. */ val pubkeyHex: String, + /** Best-effort display name of the author from the local kind:0 cache. + * Null when the author's profile hasn't been seen yet — caller renders + * the npub instead. */ + val authorDisplayName: String?, /** Unix-seconds timestamp the note was created at. */ val createdAt: Long, /** Raw content of the note (plain text, may contain Nostr URIs / hashtags). */ @@ -1074,6 +1099,12 @@ class DmMessage( val fromNpub: String, /** Hex pubkey of the sender. */ val fromPubkeyHex: String, + /** Best-effort display name of the sender from the local kind:0 cache. */ + val fromDisplayName: String?, + /** True when the active account sent this message — useful for the + * caller to distinguish "Alice said X" from "I said Y" when both + * appear in the same thread snapshot. */ + val sentByMe: Boolean, /** Plaintext message body. */ val content: String, /** Unix-seconds timestamp of the inner kind:14 event. */ @@ -1102,10 +1133,16 @@ class LiveStreamHit( val title: String?, /** Short description from the `summary` tag, or null when absent. */ val summary: String?, + /** The URL where the stream is playable (HLS / WebRTC / etc.) from + * the `streaming` tag. Null when the announcement carries no + * streaming endpoint — caller has nothing to play. */ + val streamingUrl: String?, /** Bech32 npub of the host, when a host tag is present. */ val hostNpub: String?, /** Hex pubkey of the host, when a host tag is present. */ val hostPubkeyHex: String?, + /** Best-effort display name of the host from the local kind:0 cache. */ + val hostDisplayName: String?, /** Unix-seconds timestamp the stream's `starts` tag points to. */ val startsAt: Long?, /** Unix-seconds timestamp of the kind:30311 event itself. */ From 8e1b31a55066ccf36d97dc3942d6db64b2d4461f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 23:41:58 +0000 Subject: [PATCH 16/21] =?UTF-8?q?feat(amethyst):=20four=20write=20verbs=20?= =?UTF-8?q?for=20Gemini=20=E2=80=94=20postNote,=20follow,=20unfollow,=20se?= =?UTF-8?q?ndDm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 from the signer-prompt plan, scoped to Option B (refuse NIP-55 with a typed NotSupportedException). Internal-key and NIP-46 bunker accounts can now publish from Gemini. New @AppFunction methods: * postNote(text) — kind:1 short text note. Caps at 8000 chars to catch accidentally-pasted documents; publishes to outbox relays with per-relay ack reported. * followUser(user) / unfollowUser(user) — kind:3 contact list update via FollowActions. Detects already-following / not- following and returns WriteResult.unchanged() rather than re-publishing the same kind:3. New follows stamp the relay hint from the target's cached kind:10002 write list, mirroring User.bestRelayHint(). * sendDm(recipient, text) — NIP-17 gift-wrap via DmActions.buildTextDm. Resolves per-recipient relay set through DmActions.resolveDmRelays (permissive mode — falls back through NIP-65 read to bootstrap so Gemini users don't trip on the strict kind:10050 rule). Returns one DmDelivery per wrap (recipient + sender's own copy). Signer gating — requireInProcessSigner(): * Read-only signers (npub-only login) → AppFunctionNotSupportedException "sign in with a private key or NIP-46 bunker to publish". * NIP-55 external signers (Amber) → AppFunctionNotSupportedException "open Amethyst directly to complete the action". Detected via qualified class name to avoid hard-coupling the bridge to the nip55AndroidSigner module. * NostrSignerInternal / NostrSignerRemote — sign in-process; the NIP-46 round-trip already suspends through .sign(), no special handling needed. New @AppFunctionSerializable types: * WriteResult — { changed, eventId?, publishedTo, rejectedBy } * SendDmResult — { messageEventId, deliveries: List } * DmDelivery — { recipientNpub, recipientPubkeyHex, wrapId, publishedTo, rejectedBy, relaySource } All 19 verbs now registered in the generated dispatcher (15 read + 4 write). app_metadata.xml updated so Gemini's tool picker pitches the broader surface, including the NIP-55 caveat. --- .../appfunctions/AmethystAppFunctions.kt | 346 +++++++++++++++++- amethyst/src/play/res/xml/app_metadata.xml | 4 +- 2 files changed, 344 insertions(+), 6 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 5aefa53134..bd736b5cf0 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -22,18 +22,25 @@ package com.vitorpamplona.amethyst.appfunctions import androidx.appfunctions.AppFunctionContext import androidx.appfunctions.AppFunctionInvalidArgumentException +import androidx.appfunctions.AppFunctionNotSupportedException import androidx.appfunctions.AppFunctionSerializable import androidx.appfunctions.service.AppFunction import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.actions.DmActions +import com.vitorpamplona.amethyst.commons.actions.FollowActions import com.vitorpamplona.amethyst.commons.actions.SearchActions import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -64,10 +71,15 @@ import com.vitorpamplona.quartz.utils.TimeUtils * the docs nudge that direction, but the runtime does not require it for * default-constructed classes. * - * Only read-only verbs are exposed so far. Write verbs (post, follow, zap) - * are intentionally deferred until we resolve the signer-prompt flow for - * NIP-46 / NIP-55 signers, which cannot interact with the user from a - * background AppFunctionService invocation. + * Read verbs work with any account state. Write verbs (post / follow / + * unfollow / sendDm) require a signer that can sign in-process — i.e. + * a local [NostrSignerInternal] or a remote NIP-46 bunker. NIP-55 + * external signers (Amber) are refused with + * [AppFunctionNotSupportedException] for now because the agent + * dispatch happens outside the foreground task stack — the user can't + * see the Amber approval activity from inside Gemini. See + * `amethyst/plans/2026-05-25-appfunctions-signer-prompts.md` for the + * design and the planned PendingIntent escape hatch. * * Account scoping uses the currently active account from * [com.vitorpamplona.amethyst.Amethyst.instance.sessionManager] — the same @@ -785,6 +797,245 @@ class AmethystAppFunctions { return LiveStreamsResult(streams = streams) } + // ------------------------------------------------------------------ + // Write verbs — gated on a signer that can sign without launching a + // foreground activity. See requireInProcessSigner below. + // ------------------------------------------------------------------ + + /** + * Publishes a short text note (NIP-10 kind:1) on Nostr as the + * signed-in user, broadcast to the account's configured outbox + * relays. + * + * @param text the note body. Cannot be blank; capped at 8000 + * characters so an accidentally-pasted document doesn't try to + * become a Nostr post. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun postNote( + appFunctionContext: AppFunctionContext, + text: String, + ): WriteResult { + val body = text.trim() + if (body.isEmpty()) throw AppFunctionInvalidArgumentException("text cannot be blank") + if (body.length > MAX_NOTE_LENGTH) { + throw AppFunctionInvalidArgumentException("text is $${body.length} chars; cap is $MAX_NOTE_LENGTH") + } + + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + val relays = account.outboxRelays.flow.value + if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured") + + val template = TextNoteEvent.build(body) + val signed = account.signer.sign(template) + // Mirror the foreground UI: cache the freshly-signed event so + // subsequent reads see it without waiting for a relay echo. + account.cache.justConsumeMyOwnEvent(signed) + val ack = Amethyst.instance.client.publishAndConfirmDetailed(signed, relays, PUBLISH_TIMEOUT_SECS) + + return WriteResult.from(signed.id, ack) + } + + /** + * Adds [user] to the signed-in account's NIP-02 kind:3 follow list + * and publishes the updated list. No-op when the user is already + * followed — [WriteResult.changed] reports `false` in that case. + * + * @param user npub (`npub1…`) or 64-character hex pubkey. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun followUser( + appFunctionContext: AppFunctionContext, + user: String, + ): WriteResult { + val target = decodeUserOrThrow(user) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + if (target == account.signer.pubKey) { + throw AppFunctionInvalidArgumentException("cannot follow yourself") + } + requireInProcessSigner(account.signer) + val relays = account.outboxRelays.flow.value + if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured") + + val currentList = account.kind3FollowList.getFollowListEvent() + if (currentList != null && currentList.isTaggedUser(target)) { + return WriteResult.unchanged() + } + + // Relay hint from cached kind:10002 so the follow tag points + // readers at where the target publishes. + val relayHint = + account.cache + .checkGetOrCreateUser(target) + ?.outboxRelays() + ?.firstOrNull() + + val newList = + FollowActions.buildFollow( + signer = account.signer, + pubkeyToFollow = target, + currentContactList = currentList, + relayHint = relayHint, + ) + account.cache.justConsumeMyOwnEvent(newList) + val ack = Amethyst.instance.client.publishAndConfirmDetailed(newList, relays, PUBLISH_TIMEOUT_SECS) + return WriteResult.from(newList.id, ack) + } + + /** + * Removes [user] from the signed-in account's NIP-02 kind:3 follow + * list and publishes the updated list. No-op when the user wasn't + * followed — [WriteResult.changed] reports `false`. + * + * @param user npub (`npub1…`) or 64-character hex pubkey. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun unfollowUser( + appFunctionContext: AppFunctionContext, + user: String, + ): WriteResult { + val target = decodeUserOrThrow(user) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + val relays = account.outboxRelays.flow.value + if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured") + + val currentList = account.kind3FollowList.getFollowListEvent() + if (currentList == null || !currentList.isTaggedUser(target)) { + return WriteResult.unchanged() + } + + val newList = + FollowActions.buildUnfollow( + signer = account.signer, + pubkeyToUnfollow = target, + currentContactList = currentList, + ) ?: return WriteResult.unchanged() + account.cache.justConsumeMyOwnEvent(newList) + val ack = Amethyst.instance.client.publishAndConfirmDetailed(newList, relays, PUBLISH_TIMEOUT_SECS) + return WriteResult.from(newList.id, ack) + } + + /** + * Sends a NIP-17 direct message to [recipient]. The message is + * gift-wrapped (kind:1059) per NIP-59 — only the recipient (and + * the signed-in user, who keeps their own copy) can decrypt it. + * + * Recipients without a published kind:10050 DM-inbox list fall back + * through NIP-65 read relays then bootstrap relays. If you want the + * stricter NIP-17 behavior — refuse to send when no kind:10050 is + * available — read the recipient's profile via [getProfile] first + * and check yourself; this verb defaults to permissive so Gemini + * users don't see confusing failures from an unfamiliar spec rule. + * + * @param recipient npub (`npub1…`) or 64-character hex pubkey. + * @param text the message body. Cannot be blank; capped at 8000 + * characters. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun sendDm( + appFunctionContext: AppFunctionContext, + recipient: String, + text: String, + ): SendDmResult { + val body = text.trim() + if (body.isEmpty()) throw AppFunctionInvalidArgumentException("text cannot be blank") + if (body.length > MAX_NOTE_LENGTH) { + throw AppFunctionInvalidArgumentException("text is $${body.length} chars; cap is $MAX_NOTE_LENGTH") + } + val recipientPub = decodeUserOrThrow(recipient) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + + val client = Amethyst.instance.client + val result = DmActions.buildTextDm(account.signer, recipientPub, body) + + // One wrap per recipient — for a 1:1 DM that's two (the recipient's + // copy + the sender's own copy on the sender's inbox). + val deliveries = mutableListOf() + for (wrap in result.wraps) { + val target = wrap.recipientPubKey() ?: continue + // Fetch the recipient's kind:10050 / 10051 / 10002 fresh — local + // cache may be stale for users we rarely interact with, and the + // cost is one short drain on already-warmed sockets. + val lists = + RecipientRelayFetcher.fetchRelayLists(client, target, account.outboxRelays.flow.value) + val resolution = + DmActions.resolveDmRelays( + recipientLists = lists, + bootstrap = account.outboxRelays.flow.value, + allowFallback = true, + ) + if (resolution.relays.isEmpty()) { + deliveries.add( + DmDelivery( + recipientNpub = NPub.create(target), + recipientPubkeyHex = target, + wrapId = wrap.id, + publishedTo = emptyList(), + rejectedBy = emptyList(), + relaySource = resolution.source.name.lowercase(), + ), + ) + continue + } + val ack = client.publishAndConfirmDetailed(wrap, resolution.relays, PUBLISH_TIMEOUT_SECS) + deliveries.add( + DmDelivery( + recipientNpub = NPub.create(target), + recipientPubkeyHex = target, + wrapId = wrap.id, + publishedTo = ack.filterValues { it }.keys.map { it.url }, + rejectedBy = ack.filterValues { !it }.keys.map { it.url }, + relaySource = resolution.source.name.lowercase(), + ), + ) + } + // Cache the inner kind:14 so the foreground UI sees the message + // immediately in the relevant DM thread. + account.cache.justConsumeMyOwnEvent(result.msg) + + return SendDmResult( + messageEventId = result.msg.id, + deliveries = deliveries, + ) + } + + /** + * Reject the call when the active signer can't sign in-process — + * NIP-55 external signers (Amber) need a foreground activity to + * show the user an approval prompt, which we can't launch from a + * background AppFunctionService dispatch. + * + * Throws [AppFunctionNotSupportedException] when the user's signer + * is read-only, and a typed [AppFunctionNotSupportedException] + * with a clarifying message when it's an external signer. + */ + private fun requireInProcessSigner(signer: NostrSigner) { + if (!signer.isWriteable()) { + throw AppFunctionNotSupportedException( + "Active Amethyst account is read-only (npub login). Sign in with a private key or NIP-46 bunker to publish.", + ) + } + // NostrSignerExternal lives in quartz/androidMain and isn't visible + // to commonMain — but we're already in android-app code, so the + // class is on the classpath. Reflective name-check keeps the + // dependency edge clean and avoids hard-coupling the bridge to + // the NIP-55 implementation class. + val klass = signer::class.qualifiedName + if (klass == "com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal") { + throw AppFunctionNotSupportedException( + "Amethyst is configured to use an external NIP-55 signer (Amber). " + + "Write actions from Gemini aren't supported with this signer yet — " + + "they require Amber's approval activity which can't launch from a " + + "background dispatch. Open Amethyst directly to complete the action.", + ) + } + } + + private fun notSignedIn(): AppFunctionNotSupportedException = AppFunctionNotSupportedException("No Amethyst account is signed in.") + /** * Accepts either an npub bech32 (`npub1…`) or 64-character hex * pubkey and returns the 64-char lowercase hex. Throws @@ -937,6 +1188,22 @@ class AmethystAppFunctions { * whether to fetch the full article. */ private const val LONG_FORM_SNIPPET_LIMIT = 2_000 + + /** + * Cap on the body of a Gemini-driven write (postNote / sendDm). + * Anything larger is almost certainly an accidentally-pasted + * document; bail out with a typed error instead of silently + * publishing a wall of text to relays. + */ + private const val MAX_NOTE_LENGTH = 8_000 + + /** + * Per-publish ack window. We wait this long for OK responses + * from each relay; relays that don't answer in time are + * reported as `rejectedBy` (no ack, no event). 15 s lines up + * with what `cli/Context.publish` uses. + */ + private const val PUBLISH_TIMEOUT_SECS = 15L } } @@ -1196,3 +1463,74 @@ class AccountInfoResult( ) } } + +/** + * Result of a single-event write verb (postNote / followUser / + * unfollowUser). When the verb is a no-op — already following, not + * following, content unchanged — [changed] is false and [eventId] is + * null; the relay lists are empty for the same reason. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class WriteResult( + /** True when a new event was actually signed and published. */ + val changed: Boolean, + /** Hex event id of the signed event, or null when the verb was a no-op. */ + val eventId: String?, + /** Relays that ACK'd the publish. */ + val publishedTo: List, + /** Relays that rejected the event or didn't answer in time. */ + val rejectedBy: List, +) { + companion object { + fun unchanged() = + WriteResult( + changed = false, + eventId = null, + publishedTo = emptyList(), + rejectedBy = emptyList(), + ) + + fun from( + eventId: String, + ack: Map, + ) = WriteResult( + changed = true, + eventId = eventId, + publishedTo = ack.filterValues { it }.keys.map { it.url }, + rejectedBy = ack.filterValues { !it }.keys.map { it.url }, + ) + } +} + +/** + * Per-recipient delivery status for a NIP-17 DM send. A 1:1 DM + * produces two entries — the recipient's wrap and the sender's own + * copy on their own DM-inbox relays. [relaySource] reports which + * bucket the relays were drawn from: `kind_10050`, `nip65_read`, + * `bootstrap`, or `none`. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class DmDelivery( + /** Bech32 npub of the recipient this wrap was addressed to. */ + val recipientNpub: String, + /** Hex pubkey of the recipient. */ + val recipientPubkeyHex: String, + /** Hex event id of the kind:1059 gift wrap published to this recipient. */ + val wrapId: String, + /** Relays that ACK'd this wrap. */ + val publishedTo: List, + /** Relays that rejected this wrap or didn't answer in time. */ + val rejectedBy: List, + /** Bucket the relays were resolved from: kind_10050 / nip65_read / bootstrap / none. */ + val relaySource: String, +) + +/** Result of [AmethystAppFunctions.sendDm]. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class SendDmResult( + /** Hex event id of the inner kind:14 (the plaintext message — only the + * signer and the recipient know it; relays only see the kind:1059 wraps). */ + val messageEventId: String, + /** One entry per gift-wrap delivery. */ + val deliveries: List, +) diff --git a/amethyst/src/play/res/xml/app_metadata.xml b/amethyst/src/play/res/xml/app_metadata.xml index 6bfe897850..20ebd6d298 100644 --- a/amethyst/src/play/res/xml/app_metadata.xml +++ b/amethyst/src/play/res/xml/app_metadata.xml @@ -12,5 +12,5 @@ the @AppFunction surface grows. --> + appfn:description="Amethyst is a Nostr social client. The agent can read: search Nostr profiles, notes, hashtags, and long-form articles; look up any user's profile by npub; read recent posts from the signed-in user's follows or any specific user; read recent direct messages (decrypted); list NIP-57 zaps received and total sats earned in a time window; surface notes that mention or reply to the user; list currently-live audio/video streams; and report basic account info. The agent can also write: publish short text notes, follow or unfollow other users, and send NIP-17 gift-wrapped direct messages — provided the user is signed in with a local key or NIP-46 bunker. NIP-55 external signers (Amber) are read-only from the agent for now; open Amethyst directly to publish." + appfn:displayDescription="Read and write Nostr through Amethyst" /> From 5efb5d90e51110b29e239ee6f29a2eb84249a1c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:01:13 +0000 Subject: [PATCH 17/21] feat(amethyst): zap verbs + LLM-friendly kdocs + Gemini discovery plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three deliverables: 1) Two new write verbs: * zapUser(user, sats, comment?) — builds the NIP-57 kind:9734 profile zap and fetches a BOLT11 invoice from the recipient's Lightning service. Returns the invoice — caller pastes into a Lightning wallet (no NWC auto-pay yet). 21 sats default, 1M sats cap, 280-char comment cap. * zapEvent(eventId, sats, comment?) — same but for a specific note, with full NIP-57 zap-split support via ZapActions.buildEventZapRequestsForSplits. Returns one invoice per recipient when the post carries `zap` tags. Total verb count: 21 (8 read for feeds/profiles, 3 read for identity / followers, 4 read for inbox/zaps/streams, 4 write for note/follow/unfollow/dm, 2 write for zaps). 2) Reworked every verb's kdoc first sentence into an LLM-friendly "use when..." trigger phrase. Gemini's tool picker matches user queries against the descriptions (we generate them via @AppFunction(isDescribedByKDoc = true)) — phrasing like "Find a person on Nostr by name. Use when the user wants to look someone up..." gives the model concrete prompts to recognise instead of internal NIP names. Affected: searchProfiles, getRecentFromFollows, getNotesByUser, getProfile, searchByHashtag, getActiveAccountInfo, getRecentDms, getZapsReceived, postNote, followUser, unfollowUser, sendDm, zapUser, zapEvent. 3) amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md — verification protocol for testing on-device whether Gemini's tool picker actually surfaces our verbs from natural-language prompts. Includes specific test prompts mapped to expected verbs, fallback diagnostics (clear AppSearch + restart), and the conditions under which it'd be worth defining our own @AppFunctionSchemaDefinition namespace. Plus minor: comment parameters switched to nullable (String? = null) because KSP rejects non-nullable types with defaults. --- ...026-05-26-appfunctions-gemini-discovery.md | 131 +++++ .../appfunctions/AmethystAppFunctions.kt | 507 ++++++++++++++++-- 2 files changed, 584 insertions(+), 54 deletions(-) create mode 100644 amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md diff --git a/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md b/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md new file mode 100644 index 0000000000..e66007e512 --- /dev/null +++ b/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md @@ -0,0 +1,131 @@ +# Verifying Gemini-side AppFunctions discovery + +**Date:** 2026-05-26 +**Status:** Active — answers the open question from +`2026-05-25-appfunctions-signer-prompts.md` + +The Phase 2 work proves the app side: 21 `@AppFunction` verbs are +registered, indexed by `AppFunctionManagerService`, and dispatchable +via `adb shell cmd app_function execute-app-function`. The remaining +unknown is whether **Gemini's chat UI** actually surfaces our verbs to +the user — that's a separate layer (model-side tool picker) we can't +exercise from the test command. + +## What we know + +* **Library state.** Built against `androidx.appfunctions + 1.0.0-alpha09`. Schemas (`@AppFunctionSchemaDefinition`) are + optional and the official Google sample (`android/appfunctions` + ChatApp) doesn't use them — meaning we're not at a structural + disadvantage by not defining our own. There's no canonical + `nostr.social` schema registry yet. +* **Discovery strategy.** Without schemas, Gemini's tool picker + matches on the function's natural-language description (KDoc, via + `@AppFunction(isDescribedByKDoc = true)`) and the parameter + descriptions. We've reworked every verb's first sentence to be a + use-when imperative — "Find a person on Nostr by name…" — instead + of an implementation description ("Searches kind:0 metadata…"). + +## What we don't know yet + +* Whether Gemini's model picks up our verbs at all from a typical + user query. +* Whether Gemini's `AppFunctionSearchSpec` filters by + `schemaCategory` / `schemaName` (in which case we're invisible + until we annotate) or by description (in which case we should + surface). +* What feature flags / Gemini-app versions are required. App + Functions is generally available on Android 16+, but Gemini's + third-party tool picker has shipped in waves. + +## Verification protocol + +### 1. Confirm the device is set up + +```bash +# Pixel 8 or newer on Android 16 QPR1+ +adb shell getprop ro.build.version.release +adb shell pm list packages | grep -i gemini # com.google.android.apps.bard +``` + +### 2. Reinstall the Play debug APK with the new descriptions + +```bash +./gradlew :amethyst:assemblePlayDebug +adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk +adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity +# sign in if needed, give Amethyst a few seconds to register +``` + +### 3. Confirm metadata is indexed end-to-end + +```bash +adb shell cmd app_function list-app-functions | grep -c amethyst +# should print ≥ 21 — one entry per @AppFunction across our class +``` + +### 4. Test prompts in Gemini + +These are deliberately mapped to one specific verb each. Run them in +order, take notes on which surface a tool call and which don't. + +| Prompt to Gemini | Should pick | +|---|---| +| "Find vitorpamplona on Nostr" | searchProfiles | +| "What's happening on Nostr today?" | getRecentFromFollows | +| "Who am I logged in as on Nostr?" | getActiveAccountInfo | +| "Did anyone DM me on Nostr recently?" | getRecentDms | +| "How many sats did I earn on Nostr this week?" | getZapsReceived | +| "Show me Nostr posts about bitcoin" | searchByHashtag | +| "Tell me about npub1xq5eqwlhxy3ldakahsfglccvzy4j6ayyxje5a92zu90hc05dxn7qrsns90" | getProfile | +| "What are people I follow saying on Nostr?" | getRecentFromFollows | +| "Catch me up on what Snowden's been posting" | getNotesByUser | + +For each: did Gemini offer to call the tool? Did it call the right +one? Did it render the result? + +### 5. Diagnose any miss + +If Gemini doesn't surface a verb: + +1. **Check Gemini's tools view.** In the Gemini app: + Settings → Apps. Our package should appear in the list of apps + the assistant can interact with. If it's not there at all, the + system hasn't told Gemini about us yet — wait a few minutes after + install or force-reindex by clearing AppSearch. +2. **Force a re-index.** + ```bash + adb shell pm clear --user 0 com.android.appsearch || true + adb shell am force-stop com.vitorpamplona.amethyst.debug + adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity + ``` +3. **Verify per-prompt.** If the package is listed but a specific + prompt doesn't trigger a tool call, the issue is description + matching — our use-when phrasing isn't catching that query. + Adjust the kdoc and rebuild. + +## When schemas become worth doing + +We'll move from "skipped" to "implement" if: + +1. Step 4 above shows Gemini consistently fails to surface verbs that + should obviously match (suggesting it's filtering by schema, not + description), OR +2. Another Nostr Android client ships AppFunctions and wants to + co-implement a shared schema namespace (so a Nostr-aware agent + could route to whichever client is installed). + +Until either of those happens, the simpler description-matching path +is in place and is what every public AppFunctions sample uses today. + +## Open follow-ups (independent of this verification) + +* NIP-55 (Amber) signer support — write verbs currently refuse with + `AppFunctionNotSupportedException` because we can't launch Amber's + approval activity from a background dispatch. The PendingIntent + escape hatch (Option A in the signer-prompt plan) is the next move + if NIP-55 usage matters. +* NWC auto-pay for `zapUser` / `zapEvent` — today we return the + BOLT11 invoice; the caller pastes it into a wallet. With NWC + configured we could pay automatically. +* Schema definitions if step 4 above shows we need them. diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index bd736b5cf0..5e594f651a 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -29,8 +29,10 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.actions.DmActions import com.vitorpamplona.amethyst.commons.actions.FollowActions import com.vitorpamplona.amethyst.commons.actions.SearchActions +import com.vitorpamplona.amethyst.commons.actions.ZapActions import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -39,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -88,10 +91,15 @@ import com.vitorpamplona.quartz.utils.TimeUtils */ class AmethystAppFunctions { /** - * Searches for Nostr user profiles matching [query] via NIP-50 full-text - * search across the active account's configured search relays - * (kind:10007), falling back to Amethyst's curated default search-relay - * set when none is configured. + * Find a person on Nostr by name, handle, or NIP-05. Use when the user + * wants to look someone up on Nostr ("find vitor on nostr", "search for + * jack dorsey", "who is alice@damus on nostr"), translate a display + * name to an npub, or discover a user before following / DMing / + * zapping them. + * + * Backed by NIP-50 full-text search across the active account's + * configured search relays (kind:10007), with a fallback to + * Amethyst's curated default search-relay set. * * @param query free-form search text (display name, NIP-05 handle, etc.) * @param limit max number of profiles to return — capped to 50. @@ -142,13 +150,15 @@ class AmethystAppFunctions { } /** - * Drains recent kind:1 short text notes from the people the active - * account follows. The "what's new on Nostr" verb — same query the - * Amethyst home-feed UI runs, just truncated to one batch. + * Read the user's Nostr timeline / home feed. Use when the user asks + * "what's new on Nostr", "what's happening on Nostr today", "catch me + * up on my Nostr feed", or wants a summary of recent posts from + * people they follow. * - * Queried relays: the active account's home relays (NIP-65 outbox + - * any private storage + local relays). Same source the UI uses, so - * Gemini sees what the user would see on their timeline. + * Drains recent kind:1 short text notes from the people the active + * account follows; the same query the Amethyst home-feed UI runs, + * truncated to one batch. Queries the account's home relays (NIP-65 + * outbox + any private storage + local relays). * * @param limit max notes to return, capped to 200. Default 30. */ @@ -191,13 +201,15 @@ class AmethystAppFunctions { } /** - * Recent kind:1 short text notes from a specific user. Use this when - * the user asks "what did Vitor post recently?" or "catch me up on - * Snowden" — pass that user's npub or 64-hex pubkey. + * Read recent Nostr posts from a specific user. Use when the user + * asks "what did Snowden post recently on Nostr", "catch me up on + * what Jack has been posting", "show me Alice's latest notes", or + * wants to see one specific Nostr user's activity. * - * Queried relays: prefer the target's NIP-65 write relays (where they - * publish) when their kind:10002 is cached locally; fall back to the - * active account's home relays. + * Pass the target user's npub or hex pubkey — use [searchProfiles] + * first if you only have a display name. Queries the target's + * NIP-65 write relays when cached, falling back to the active + * account's home relays. * * @param user npub (`npub1…`) or 64-character hex pubkey. * @param limit max notes to return, capped to 100. Default 20. @@ -249,9 +261,14 @@ class AmethystAppFunctions { } /** - * Looks up one Nostr profile by [user] (npub or 64-hex). Returns the - * latest kind:0 metadata available — checks the local cache first, - * falls back to a short network drain. + * Look up one Nostr profile by npub or hex pubkey. Use when the user + * asks "who is npub1…", "tell me about [npub]", "what's [user]'s + * Nostr profile", or wants the bio / NIP-05 / Lightning address of a + * specific Nostr user. + * + * Returns the latest kind:0 metadata — cache-first, with a short + * network fallback when the user's profile hasn't been observed + * locally yet. * * @param user npub (`npub1…`) or 64-character hex pubkey. */ @@ -312,10 +329,14 @@ class AmethystAppFunctions { } /** - * Find kind:1 short text notes tagged with a hashtag (NIP-12 `t` - * tag). For "find me Nostr posts about Bitcoin" — pass `bitcoin`, - * not `#bitcoin`. The hashtag is lowercased before matching, which - * is the convention most Nostr clients (Amethyst included) follow. + * Find Nostr posts about a topic via hashtag. Use when the user asks + * "show me Nostr posts about Bitcoin", "find Nostr discussion of + * #Tor", "what's the Nostr take on [topic]", or wants to browse + * conversation about a specific subject. + * + * Pass the tag value without the leading `#` — "bitcoin", not + * "#bitcoin". The hashtag is lowercased before matching (the + * convention most Nostr clients follow). * * @param hashtag the tag value without the leading `#`. * @param limit max notes to return, capped to 100. Default 30. @@ -359,10 +380,15 @@ class AmethystAppFunctions { } /** - * Returns who the user is currently signed in as on Nostr — their - * npub, best-effort display name, follow count, and how many relays - * are configured for outbox / inbox. Diagnostic verb for queries - * like "who am I logged in as?" or "what's my npub?". + * Report who the user is signed in as on Nostr. Use when the user + * asks "who am I logged in as on Nostr", "what's my npub", "what's + * my Nostr identity", "how many people do I follow on Nostr", or + * any other "tell me about my Nostr account" query. + * + * Returns the active account's npub, display name, NIP-05 handle, + * follow count, and how many relays are configured for outbox / + * DM inbox. Use this for Nostr-side diagnostics rather than as a + * general "who am I" answer. */ @AppFunction(isDescribedByKDoc = true) suspend fun getActiveAccountInfo(appFunctionContext: AppFunctionContext): AccountInfoResult { @@ -519,13 +545,14 @@ class AmethystAppFunctions { } /** - * Total sats received as NIP-57 zaps in the last [hoursBack] hours, - * plus a count of distinct zappers. For "did I earn any sats - * today?" — defaults to 24 hours. + * Report how many sats the user earned on Nostr in a time window. + * Use when the user asks "did I get any zaps today", "how many sats + * did I earn on Nostr this week", "did anyone zap my last post", + * or wants a summary of incoming NIP-57 Lightning zaps. * - * Each kind:9735 receipt carries a `bolt11` invoice; we parse the - * amount out and sum them. Receipts without a parseable amount are - * counted but contribute 0 sats. + * Drains kind:9735 zap receipts addressed to the user in the window + * and parses the bolt11 invoice from each to compute total sats. + * Returns total + per-window zap count + unique zapper count. * * @param hoursBack window size in hours. Capped to 168 (7 days), * default 24. @@ -595,12 +622,21 @@ class AmethystAppFunctions { } /** - * Recent direct messages addressed to the active account. Drains - * NIP-17 gift wraps from inbox relays, decrypts each, and returns - * the inner kind:14 messages. + * Read recent Nostr direct messages. Use when the user asks "did I + * get any Nostr DMs", "what did Alice DM me", "show me my recent + * Nostr messages", "summarize my unread Nostr DMs", or wants + * decrypted message content (not just notifications) from Nostr. + * + * Drains NIP-17 gift wraps from the active account's DM-inbox + * relays, decrypts each in-process (Amethyst is the only place + * the user's NIP-44 keys live), and returns the inner kind:14 + * messages with sender display names attached. File-attachment + * DMs (kind:15) are filtered out for now to keep the response + * small. * * @param peer optional npub/hex; when set, only returns messages - * from that specific peer. When null, returns from anyone. + * to/from that specific peer. When null, returns conversations + * with anyone. * @param hoursBack window size in hours. Capped to 168 (7 days), * default 24. * @param limit max messages to return, capped to 100. Default 20. @@ -803,9 +839,14 @@ class AmethystAppFunctions { // ------------------------------------------------------------------ /** - * Publishes a short text note (NIP-10 kind:1) on Nostr as the - * signed-in user, broadcast to the account's configured outbox - * relays. + * Publish a short text note on Nostr. Use when the user asks "post + * this to Nostr", "tweet this on Nostr", "share [X] on Nostr", + * "publish a Nostr note saying [X]", or any other "send to Nostr" + * intent for plain-text content. + * + * Publishes a NIP-10 kind:1 short text note as the signed-in user, + * broadcast to the account's configured outbox relays. Returns per- + * relay ack so the caller can confirm the post landed. * * @param text the note body. Cannot be blank; capped at 8000 * characters so an accidentally-pasted document doesn't try to @@ -838,9 +879,14 @@ class AmethystAppFunctions { } /** + * Follow a user on Nostr. Use when the user asks "follow [X] on + * Nostr", "add [npub] to my Nostr follows", or "subscribe to + * [user]" with a Nostr context. Idempotent — re-following someone + * already followed is a safe no-op. + * * Adds [user] to the signed-in account's NIP-02 kind:3 follow list - * and publishes the updated list. No-op when the user is already - * followed — [WriteResult.changed] reports `false` in that case. + * and publishes the updated list. [WriteResult.changed] reports + * `false` when the user is already followed. * * @param user npub (`npub1…`) or 64-character hex pubkey. */ @@ -884,9 +930,14 @@ class AmethystAppFunctions { } /** - * Removes [user] from the signed-in account's NIP-02 kind:3 follow - * list and publishes the updated list. No-op when the user wasn't - * followed — [WriteResult.changed] reports `false`. + * Unfollow a user on Nostr. Use when the user asks "unfollow [X] + * on Nostr", "remove [npub] from my Nostr follows", or "stop + * following [user]" with a Nostr context. Idempotent — unfollowing + * someone the user wasn't following is a safe no-op. + * + * Removes [user] from the signed-in account's NIP-02 kind:3 + * follow list and publishes the updated list. [WriteResult.changed] + * reports `false` when the user wasn't followed. * * @param user npub (`npub1…`) or 64-character hex pubkey. */ @@ -918,16 +969,16 @@ class AmethystAppFunctions { } /** - * Sends a NIP-17 direct message to [recipient]. The message is - * gift-wrapped (kind:1059) per NIP-59 — only the recipient (and - * the signed-in user, who keeps their own copy) can decrypt it. + * Send a direct message to a user on Nostr. Use when the user asks + * "DM [X] on Nostr", "send a Nostr message to [user] saying [Y]", + * "message [npub] on Nostr", or any other "send a private message" + * intent in a Nostr context. * - * Recipients without a published kind:10050 DM-inbox list fall back - * through NIP-65 read relays then bootstrap relays. If you want the - * stricter NIP-17 behavior — refuse to send when no kind:10050 is - * available — read the recipient's profile via [getProfile] first - * and check yourself; this verb defaults to permissive so Gemini - * users don't see confusing failures from an unfamiliar spec rule. + * The message is gift-wrapped (kind:1059) per NIP-59 — only the + * recipient (and the signed-in user, who keeps their own copy) + * can decrypt it. Recipients without a published kind:10050 + * DM-inbox list fall back through NIP-65 read relays then + * bootstrap relays. * * @param recipient npub (`npub1…`) or 64-character hex pubkey. * @param text the message body. Cannot be blank; capped at 8000 @@ -1002,6 +1053,269 @@ class AmethystAppFunctions { ) } + /** + * Tip a Nostr user with Lightning sats (NIP-57 profile zap). Use + * when the user asks "zap [X] on Nostr", "tip [user] [N] sats", + * "send a Lightning tip to [npub]", or "thank [user] with sats". + * + * Builds the NIP-57 kind:9734 zap request and fetches a BOLT11 + * invoice from the recipient's Lightning service. Returns the + * invoice — the user pastes it into a Lightning wallet to settle. + * (NWC auto-pay is a separate verb, not yet exposed.) + * + * Defaults to 21 sats — the canonical "small thank-you" zap. Cap + * is 1,000,000 sats so an accidental tip can't drain a wallet. + * + * @param user npub (`npub1…`) or 64-character hex pubkey of the + * zap recipient. + * @param sats amount to zap, in whole sats. Capped at 1,000,000 + * sats. Default 21. + * @param comment optional message to attach to the zap. Capped at + * 280 characters. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun zapUser( + appFunctionContext: AppFunctionContext, + user: String, + sats: Long = 21, + comment: String? = null, + ): ZapResult { + val cappedSats = sats.coerceIn(1L, MAX_ZAP_SATS) + val trimmedComment = comment.orEmpty().trim().take(MAX_ZAP_COMMENT_LENGTH) + val recipientPub = decodeUserOrThrow(user) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + val client = Amethyst.instance.client + + // Pull the recipient's kind:0 — needs lnAddress to receive the zap. + val metadata = + account.cache + .checkGetOrCreateUser(recipientPub) + ?.metadataOrNull() + ?.flow + ?.value + ?.info + ?.let { extractLnAddressFromMetadata(it) } + ?: fetchProfileForZap(client, account, recipientPub) + ?: throw AppFunctionInvalidArgumentException( + "No kind:0 metadata for $user — recipient must have a Nostr profile first.", + ) + val lnAddress = + metadata.takeIf { it.isNotBlank() } + ?: throw AppFunctionInvalidArgumentException( + "Recipient has no lud16 or lud06 in their profile — they can't receive Lightning zaps.", + ) + + val zapRequest = + ZapActions.buildUserZapRequest( + signer = account.signer, + recipientPubkey = recipientPub, + amountMillisats = ZapActions.satsToMillisats(cappedSats), + inboxRelays = account.nip65RelayList.inboxFlow.value, + comment = trimmedComment, + zapType = LnZapEvent.ZapType.PUBLIC, + ) + + val invoice = fetchInvoiceOrThrow(lnAddress, cappedSats, trimmedComment, zapRequest) + + return ZapResult( + recipientNpub = NPub.create(recipientPub), + recipientPubkeyHex = recipientPub, + recipientDisplayName = displayNameOf(recipientPub), + lnAddress = lnAddress, + amountSats = cappedSats, + comment = trimmedComment, + invoice = invoice, + zapRequestId = zapRequest.id, + ) + } + + /** + * Zap a specific Nostr note (NIP-57 event zap). Use when the user + * asks "zap this Nostr post", "tip the author of [event id]", + * "send sats for that Nostr note about [X]", or "boost this Nostr + * post with sats". + * + * Honors NIP-57 zap-split tags — a post with multiple `zap` tags + * produces one invoice per recipient, proportional to weight, so + * a multi-party collab post pays everyone correctly. Returns one + * BOLT11 invoice per recipient; user pays each in a Lightning + * wallet to complete the zap. + * + * @param eventId 64-character hex id of the note to zap. Must be + * in the local cache — get it via [getNotesByUser] / + * [getRecentFromFollows] / [searchByHashtag] / [searchNotes] + * first. + * @param sats total amount to zap, in whole sats. Capped at + * 1,000,000. + * @param comment optional message attached to every zap request. + * Capped at 280 characters. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun zapEvent( + appFunctionContext: AppFunctionContext, + eventId: String, + sats: Long = 21, + comment: String? = null, + ): ZapEventResult { + if (eventId.length != 64) { + throw AppFunctionInvalidArgumentException("eventId must be 64-character hex") + } + val cappedSats = sats.coerceIn(1L, MAX_ZAP_SATS) + val trimmedComment = comment.orEmpty().trim().take(MAX_ZAP_COMMENT_LENGTH) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn() + requireInProcessSigner(account.signer) + + val note = + account.cache.getNoteIfExists(eventId) + ?: throw AppFunctionInvalidArgumentException( + "Event $eventId not in local cache. Fetch it via getNotesByUser or " + + "getRecentFromFollows first, or open the note in Amethyst.", + ) + val event = + note.event + ?: throw AppFunctionInvalidArgumentException( + "Event $eventId is referenced locally but its content hasn't been observed yet.", + ) + + val client = Amethyst.instance.client + val totalMsats = ZapActions.satsToMillisats(cappedSats) + + // Lookups for the split resolver — first try the local cache, + // then fall back to a one-shot network drain. + val lookupLnAddress: suspend (HexKey) -> String? = { pk -> + account.cache + .checkGetOrCreateUser(pk) + ?.metadataOrNull() + ?.lnAddress() + ?: fetchProfileForZap(client, account, pk) + } + val lookupInboxRelays: suspend (HexKey) -> Set = { pk -> + account.cache + .checkGetOrCreateUser(pk) + ?.inboxRelays() + ?.toSet() + .orEmpty() + } + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = account.signer, + zappedEvent = event, + totalAmountMillisats = totalMsats, + senderInboxRelays = account.nip65RelayList.inboxFlow.value, + lookupLnAddress = lookupLnAddress, + lookupInboxRelays = lookupInboxRelays, + comment = trimmedComment, + zapType = LnZapEvent.ZapType.PUBLIC, + ) + if (requests.isEmpty()) { + throw AppFunctionInvalidArgumentException( + "No payable recipients — neither the author nor any zap-split recipient has a usable Lightning address.", + ) + } + + val invoices = + requests.map { req -> + val shareSats = req.amountMillisats / 1000 + val result = + runCatching { + fetchInvoiceOrThrow( + lnAddress = req.recipient.lnAddress, + sats = shareSats, + comment = trimmedComment, + zapRequest = req.request, + ) + } + ZapInvoice( + recipientNpub = req.recipient.pubkey?.let { NPub.create(it) }, + recipientPubkeyHex = req.recipient.pubkey, + recipientDisplayName = req.recipient.pubkey?.let { displayNameOf(it) }, + lnAddress = req.recipient.lnAddress, + weight = req.recipient.weight, + amountSats = shareSats, + invoice = result.getOrNull(), + invoiceError = result.exceptionOrNull()?.message, + zapRequestId = req.request.id, + ) + } + + return ZapEventResult( + zappedEventId = eventId, + requestedSats = cappedSats, + billedSats = invoices.sumOf { it.amountSats }, + comment = trimmedComment, + invoices = invoices, + ) + } + + /** Read lnAddress out of an already-resolved UserMetadata. */ + private fun extractLnAddressFromMetadata(info: com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata): String? = info.lnAddress() + + /** + * Cache miss path for zap recipient profile lookup. Drain the + * recipient's NIP-65 outbox / our home relays for their kind:0; + * returns the lnAddress directly so callers don't have to re-parse + * the metadata blob. + */ + private suspend fun fetchProfileForZap( + client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient, + account: com.vitorpamplona.amethyst.model.Account, + pubkey: HexKey, + ): String? { + val relays = + account.cache + .checkGetOrCreateUser(pubkey) + ?.outboxRelays() + ?.toSet() + ?.ifEmpty { account.homeRelays.flow.value } + ?: account.homeRelays.flow.value + if (relays.isEmpty()) return null + + val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey), limit = 1) + return client + .fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ).mapNotNull { it as? MetadataEvent } + .maxByOrNull { it.createdAt } + ?.contactMetaData() + ?.lnAddress() + } + + /** + * LNURL-pay round-trip: resolves the LN address to a callback URL, + * posts the zap request, returns the BOLT11 invoice. Uses + * Amethyst's roleBasedHttpClientBuilder so the request honors the + * user's Tor / money-routing preferences. + */ + private suspend fun fetchInvoiceOrThrow( + lnAddress: String, + sats: Long, + comment: String, + zapRequest: com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent, + ): String { + // Compute the LNURL-pay endpoint so we can ask the privacy-aware + // HttpClient builder for the right OkHttpClient for that host. + val endpointUrl = + LightningAddressResolver(httpClient = okhttp3.OkHttpClient()).assembleUrl(lnAddress) + ?: throw AppFunctionInvalidArgumentException("Couldn't resolve LN address '$lnAddress' to an LNURL-pay URL.") + val client = Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForMoney(endpointUrl) + val resolver = LightningAddressResolver(httpClient = client) + val result = + resolver.fetchInvoice( + lnAddress = lnAddress, + milliSats = ZapActions.satsToMillisats(sats), + message = comment, + zapRequest = zapRequest, + ) + return when (result) { + is LightningAddressResolver.Result.Success -> result.invoice + is LightningAddressResolver.Result.Error -> + throw AppFunctionInvalidArgumentException("Lightning service rejected the zap: ${result.message}") + } + } + /** * Reject the call when the active signer can't sign in-process — * NIP-55 external signers (Amber) need a foreground activity to @@ -1204,6 +1518,16 @@ class AmethystAppFunctions { * with what `cli/Context.publish` uses. */ private const val PUBLISH_TIMEOUT_SECS = 15L + + /** Upper bound on a single zap. Anything above this is almost + * certainly a typo; bail out instead of letting Gemini bill + * the user a million sats by accident. */ + private const val MAX_ZAP_SATS = 1_000_000L + + /** LN providers typically reject longer comments — capping at + * 280 keeps us under the most aggressive ceilings while still + * fitting a tweet-length thank-you note. */ + private const val MAX_ZAP_COMMENT_LENGTH = 280 } } @@ -1534,3 +1858,78 @@ class SendDmResult( /** One entry per gift-wrap delivery. */ val deliveries: List, ) + +/** + * Result of [AmethystAppFunctions.zapUser]. Carries the BOLT11 invoice + * the user needs to pay in their Lightning wallet — this verb doesn't + * auto-pay (NWC integration is a separate, not-yet-exposed verb). + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ZapResult( + /** Bech32 npub of the zap recipient. */ + val recipientNpub: String, + /** Hex pubkey of the recipient. */ + val recipientPubkeyHex: String, + /** Best-effort display name from the local kind:0 cache. */ + val recipientDisplayName: String?, + /** LN address the invoice was fetched from. */ + val lnAddress: String, + /** Amount actually requested (after capping). */ + val amountSats: Long, + /** Comment attached to the zap (truncated to 280 chars). */ + val comment: String, + /** BOLT11 invoice the user pastes into a Lightning wallet. */ + val invoice: String, + /** Hex event id of the signed kind:9734 zap request. */ + val zapRequestId: String, +) + +/** + * Per-recipient BOLT11 invoice for an event zap. Multiple invoices + * appear when the zapped note carries NIP-57 zap-split tags. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ZapInvoice( + /** Bech32 npub of the recipient, or null when the split tag carried + * only an LN address with no pubkey. */ + val recipientNpub: String?, + /** Hex pubkey of the recipient, or null when only an LN address was given. */ + val recipientPubkeyHex: String?, + /** Best-effort display name from the cache, when the recipient is known. */ + val recipientDisplayName: String?, + /** LN address the invoice was fetched from. */ + val lnAddress: String, + /** Relative weight in the zap split — 1.0 for unweighted recipients. */ + val weight: Double, + /** This recipient's share of the total in whole sats. */ + val amountSats: Long, + /** BOLT11 invoice, or null when the Lightning provider failed + * (see [invoiceError] for the reason). */ + val invoice: String?, + /** Failure reason from the Lightning provider when [invoice] is null. */ + val invoiceError: String?, + /** Hex event id of this recipient's kind:9734 zap request. */ + val zapRequestId: String, +) + +/** + * Result of [AmethystAppFunctions.zapEvent]. Total billed sats may + * differ from requested by a few sats due to whole-sat rounding in the + * splits — same drift the foreground UI has. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class ZapEventResult( + /** Hex event id of the note being zapped. */ + val zappedEventId: String, + /** Total sats the caller asked for (capped, post-validation). */ + val requestedSats: Long, + /** Sum of per-recipient sats actually billed across all invoices. */ + val billedSats: Long, + /** Comment attached to every zap request. */ + val comment: String, + /** One invoice per recipient — multiple entries when the note has + * NIP-57 zap-split tags. Pay each one in a Lightning wallet to + * complete the zap; invoices with non-null [ZapInvoice.invoiceError] + * couldn't be fetched and won't go through. */ + val invoices: List, +) From d0f6739a306fc03240f3867732f28d1afcaf101b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 13:37:05 +0000 Subject: [PATCH 18/21] feat(amethyst): NWC auto-pay for zapUser and zapEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning a BOLT11 invoice for the user to paste somewhere defeated the point of "Gemini, zap Alice 21 sats". Now when the active account has a Nostr Wallet Connect (NIP-47) wallet configured in Amethyst, both zap verbs pay the invoice automatically over NIP-47 and report the outcome inline. Implementation: * payViaNwcOrNull(account, bolt11, zappedNote) — null when no NWC set up (caller falls back to manual). Otherwise wraps the callback-based Account.sendZapPaymentRequestFor in a CompletableDeferred + withTimeoutOrNull. 30s budget; if the wallet doesn't answer in that window the caller sees an nwcError of "wallet didn't respond within 30s" and still has the raw invoice to fall back on. * Decodes the wallet's response: PayInvoiceSuccessResponse carries the preimage, PayInvoiceErrorResponse carries a typed code + message, NwcErrorResponse covers transport-level errors, null means "couldn't decrypt the reply" (rare — wallet misconfigured or our signer rejected). Each case maps to a typed NwcOutcome the verbs can render. * ZapResult / ZapInvoice grow four fields: nwcAttempted, nwcPaid, nwcPreimage, nwcError. The invoice is still always returned so Gemini can show it as a manual-payment fallback when NWC isn't configured or rejects. zapEvent attempts each split independently — one wallet failure doesn't block the rest. Kdoc updates note the NWC behavior so the LLM picks up "if NWC is configured, this just works" — that's the user-visible promise of asking Gemini to tip someone. --- .../appfunctions/AmethystAppFunctions.kt | 172 ++++++++++++++++-- 1 file changed, 157 insertions(+), 15 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 5e594f651a..be24614139 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -50,10 +50,16 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull /** * Bridge that exposes Amethyst's "verbs" (commons/.../actions/) to the Android @@ -1058,10 +1064,12 @@ class AmethystAppFunctions { * when the user asks "zap [X] on Nostr", "tip [user] [N] sats", * "send a Lightning tip to [npub]", or "thank [user] with sats". * - * Builds the NIP-57 kind:9734 zap request and fetches a BOLT11 - * invoice from the recipient's Lightning service. Returns the - * invoice — the user pastes it into a Lightning wallet to settle. - * (NWC auto-pay is a separate verb, not yet exposed.) + * Builds the NIP-57 kind:9734 zap request, fetches a BOLT11 + * invoice from the recipient's Lightning service, and — when the + * user has a Nostr Wallet Connect wallet configured in Amethyst — + * pays the invoice automatically over NIP-47. Falls back to + * returning the invoice for manual payment when no NWC wallet is + * set up. * * Defaults to 21 sats — the canonical "small thank-you" zap. Cap * is 1,000,000 sats so an accidental tip can't drain a wallet. @@ -1118,6 +1126,13 @@ class AmethystAppFunctions { val invoice = fetchInvoiceOrThrow(lnAddress, cappedSats, trimmedComment, zapRequest) + // If the user has a Nostr Wallet Connect wallet configured, pay + // the invoice automatically over NIP-47 so Gemini can answer + // "I zapped Alice 21 sats" instead of "here's a BOLT11 invoice + // for you to paste somewhere." Falls back to manual when NWC + // isn't set up or the wallet declines. + val nwc = payViaNwcOrNull(account, invoice, null) + return ZapResult( recipientNpub = NPub.create(recipientPub), recipientPubkeyHex = recipientPub, @@ -1127,6 +1142,10 @@ class AmethystAppFunctions { comment = trimmedComment, invoice = invoice, zapRequestId = zapRequest.id, + nwcAttempted = nwc != null, + nwcPaid = nwc?.success == true, + nwcPreimage = nwc?.preimage, + nwcError = nwc?.errorMessage, ) } @@ -1138,9 +1157,11 @@ class AmethystAppFunctions { * * Honors NIP-57 zap-split tags — a post with multiple `zap` tags * produces one invoice per recipient, proportional to weight, so - * a multi-party collab post pays everyone correctly. Returns one - * BOLT11 invoice per recipient; user pays each in a Lightning - * wallet to complete the zap. + * a multi-party collab post pays everyone correctly. When the user + * has a Nostr Wallet Connect wallet configured, every split is + * paid automatically over NIP-47 and the result reports per- + * recipient success / failure. Without NWC, the verb returns the + * BOLT11 invoices for manual payment. * * @param eventId 64-character hex id of the note to zap. Must be * in the local cache — get it via [getNotesByUser] / @@ -1218,7 +1239,7 @@ class AmethystAppFunctions { val invoices = requests.map { req -> val shareSats = req.amountMillisats / 1000 - val result = + val invoiceResult = runCatching { fetchInvoiceOrThrow( lnAddress = req.recipient.lnAddress, @@ -1227,6 +1248,11 @@ class AmethystAppFunctions { zapRequest = req.request, ) } + val invoice = invoiceResult.getOrNull() + // Try NWC for every invoice that came back. Failed splits + // stay as a manual invoice with nwcError set — the others + // still go through. + val nwc = invoice?.let { payViaNwcOrNull(account, it, note) } ZapInvoice( recipientNpub = req.recipient.pubkey?.let { NPub.create(it) }, recipientPubkeyHex = req.recipient.pubkey, @@ -1234,9 +1260,13 @@ class AmethystAppFunctions { lnAddress = req.recipient.lnAddress, weight = req.recipient.weight, amountSats = shareSats, - invoice = result.getOrNull(), - invoiceError = result.exceptionOrNull()?.message, + invoice = invoice, + invoiceError = invoiceResult.exceptionOrNull()?.message, zapRequestId = req.request.id, + nwcAttempted = nwc != null, + nwcPaid = nwc?.success == true, + nwcPreimage = nwc?.preimage, + nwcError = nwc?.errorMessage, ) } @@ -1283,6 +1313,87 @@ class AmethystAppFunctions { ?.lnAddress() } + /** Internal result of [payViaNwcOrNull]. */ + private data class NwcOutcome( + val success: Boolean, + val preimage: String?, + val errorMessage: String?, + ) + + /** + * Try to pay [bolt11] through the active account's Nostr Wallet + * Connect setup. Returns null when no NWC wallet is configured — + * caller should fall back to surfacing the invoice for manual + * payment. Returns an outcome with [NwcOutcome.success] = true on + * a wallet-confirmed payment, false (with [NwcOutcome.errorMessage] + * set) on rejection or timeout. + * + * The wallet's response can take a few seconds; bounded by + * [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall the + * dispatch. + */ + private suspend fun payViaNwcOrNull( + account: com.vitorpamplona.amethyst.model.Account, + bolt11: String, + zappedNote: com.vitorpamplona.amethyst.model.Note?, + ): NwcOutcome? { + if (!account.nip47SignerState.hasWalletConnectSetup()) return null + + val deferred = CompletableDeferred() + // sendZapPaymentRequestFor fires onResponse exactly once when + // the wallet replies (success, error, or NwcError). On timeout + // we discard the late response. + account.sendZapPaymentRequestFor(bolt11, zappedNote) { response -> + if (!deferred.isCompleted) deferred.complete(response) + } + val response = + withTimeoutOrNull(NWC_PAYMENT_TIMEOUT_MS) { deferred.await() } + ?: return NwcOutcome( + success = false, + preimage = null, + errorMessage = "NWC wallet didn't respond within ${NWC_PAYMENT_TIMEOUT_MS / 1000}s", + ) + + return when (response) { + is PayInvoiceSuccessResponse -> + NwcOutcome( + success = true, + preimage = response.result?.preimage, + errorMessage = null, + ) + is PayInvoiceErrorResponse -> + NwcOutcome( + success = false, + preimage = null, + errorMessage = + response.error?.message + ?: response.error?.code?.name + ?: "wallet returned an unspecified pay_invoice error", + ) + is NwcErrorResponse -> + NwcOutcome( + success = false, + preimage = null, + errorMessage = + response.error?.message + ?: response.error?.code?.name + ?: "wallet returned an NWC error", + ) + null -> + NwcOutcome( + success = false, + preimage = null, + errorMessage = "NWC wallet returned null response (could not decrypt)", + ) + else -> + NwcOutcome( + success = false, + preimage = null, + errorMessage = "Unexpected NWC response type: ${response::class.simpleName}", + ) + } + } + /** * LNURL-pay round-trip: resolves the LN address to a callback URL, * posts the zap request, returns the BOLT11 invoice. Uses @@ -1528,6 +1639,12 @@ class AmethystAppFunctions { * 280 keeps us under the most aggressive ceilings while still * fitting a tweet-length thank-you note. */ private const val MAX_ZAP_COMMENT_LENGTH = 280 + + /** Max time to wait for an NWC wallet to respond to a + * pay_invoice request. Mobile wallets typically settle in + * a few seconds; 30s is generous without letting a stuck + * wallet stall the dispatch indefinitely. */ + private const val NWC_PAYMENT_TIMEOUT_MS = 30_000L } } @@ -1860,9 +1977,11 @@ class SendDmResult( ) /** - * Result of [AmethystAppFunctions.zapUser]. Carries the BOLT11 invoice - * the user needs to pay in their Lightning wallet — this verb doesn't - * auto-pay (NWC integration is a separate, not-yet-exposed verb). + * Result of [AmethystAppFunctions.zapUser]. Always carries the BOLT11 + * invoice so the caller can fall back to manual payment; when the + * user has a Nostr Wallet Connect (NIP-47) wallet configured, the + * verb also attempts to pay the invoice via that wallet and the + * `nwc*` fields report the outcome. */ @AppFunctionSerializable(isDescribedByKDoc = true) class ZapResult( @@ -1878,15 +1997,28 @@ class ZapResult( val amountSats: Long, /** Comment attached to the zap (truncated to 280 chars). */ val comment: String, - /** BOLT11 invoice the user pastes into a Lightning wallet. */ + /** BOLT11 invoice — always set. Pay this manually if [nwcPaid] + * is false. */ val invoice: String, /** Hex event id of the signed kind:9734 zap request. */ val zapRequestId: String, + /** True when the user has NWC configured and we tried to auto-pay. + * False means the caller should surface the invoice for manual + * payment. */ + val nwcAttempted: Boolean, + /** True only when an NWC wallet confirmed the payment. */ + val nwcPaid: Boolean, + /** Payment preimage from the wallet on success, otherwise null. */ + val nwcPreimage: String?, + /** Failure reason when [nwcAttempted] is true but [nwcPaid] is false. */ + val nwcError: String?, ) /** * Per-recipient BOLT11 invoice for an event zap. Multiple invoices - * appear when the zapped note carries NIP-57 zap-split tags. + * appear when the zapped note carries NIP-57 zap-split tags. When NWC + * is configured we try to pay each invoice automatically; per-split + * NWC results are reported in the `nwc*` fields. */ @AppFunctionSerializable(isDescribedByKDoc = true) class ZapInvoice( @@ -1910,6 +2042,16 @@ class ZapInvoice( val invoiceError: String?, /** Hex event id of this recipient's kind:9734 zap request. */ val zapRequestId: String, + /** True when NWC was configured and we tried to auto-pay this + * invoice. False when no NWC was set up or the invoice itself + * couldn't be fetched. */ + val nwcAttempted: Boolean, + /** True only when an NWC wallet confirmed the payment for this split. */ + val nwcPaid: Boolean, + /** Payment preimage from the wallet on success, otherwise null. */ + val nwcPreimage: String?, + /** Failure reason when [nwcAttempted] is true but [nwcPaid] is false. */ + val nwcError: String?, ) /** From 4617be20680645e64aeed6bb5a7db17195beb389 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 15:40:34 +0000 Subject: [PATCH 19/21] chore(amethyst): collapse unreachable NWC `when` branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withTimeoutOrNull(deferred.await()) returns a flattened Response? — both "timeout" and "wallet sent null" produce null, and we already catch null via the elvis-return above. The explicit `null ->` arm in the response switch was dead code; the compiler warned about it. Folded the "wallet sent null we couldn't decrypt" case into the timeout error message since they're indistinguishable to the caller. --- .../amethyst/appfunctions/AmethystAppFunctions.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index be24614139..3fc5be7075 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -1351,7 +1351,9 @@ class AmethystAppFunctions { ?: return NwcOutcome( success = false, preimage = null, - errorMessage = "NWC wallet didn't respond within ${NWC_PAYMENT_TIMEOUT_MS / 1000}s", + errorMessage = + "NWC wallet didn't respond within ${NWC_PAYMENT_TIMEOUT_MS / 1000}s, " + + "or returned a malformed reply we couldn't decrypt", ) return when (response) { @@ -1379,12 +1381,6 @@ class AmethystAppFunctions { ?: response.error?.code?.name ?: "wallet returned an NWC error", ) - null -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = "NWC wallet returned null response (could not decrypt)", - ) else -> NwcOutcome( success = false, From dc2c7f9be176cdcb084edf59624289bb6bbf60c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 15:58:30 +0000 Subject: [PATCH 20/21] =?UTF-8?q?feat(amethyst):=20getFeedDigest=20verb=20?= =?UTF-8?q?=E2=80=94=20feed-summary=20surface=20for=20the=20LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New verb: getFeedDigest(hoursBack, maxNotes). Use when the user asks "summarize my Nostr feed", "give me a digest of what my follows posted today", "recap Nostr", or any other summary / digest / recap intent. Returns a structured snapshot for AI summary instead of a raw note list: total note count, unique author count, top hashtags (≤10) and top mentioned users (≤10) — with display names resolved from the local kind:0 cache — alongside the trimmed note body. The LLM uses the aggregate signals to write a one-paragraph "the conversation focused on X, with N people posting about Y" instead of having to re-derive frequencies from a raw list. Implementation: * Shared core extracted into fetchFollowFeed(account, since, limit) so getRecentFromFollows and getFeedDigest don't duplicate the drain logic. * Over-fetches by 3× the visible cap so stats are computed over a larger sample than the LLM sees, capped at 500 events for bounded on-device work. * Hashtag bucketing: lowercases + strips leading #, so #Bitcoin and #bitcoin collapse. * Mention bucketing: skips self-mentions (some clients tag the author themself, not useful for the digest). New @AppFunctionSerializable result types: * HashtagFrequency, MentionFrequency — count + identifier. * FeedDigestResult — windowHours, totalNoteCount, uniqueAuthorCount, topHashtags, topMentions, notes. Total verb count: 22. app_metadata.xml updated so Gemini's tool picker can pitch the summary surface specifically. Known scope: currently returns kind:1 from the user's kind:3 follow list — does NOT match the in-app home feed exactly. The home feed includes reposts, long-form, polls, comments, etc., respects the user's currently selected NIP-51 list, and filters muted users. Aligning the digest to the home feed (via HomeNewThreadFeedFilter against LocalCache) is a documented follow-up. --- .../appfunctions/AmethystAppFunctions.kt | 213 ++++++++++++++++-- amethyst/src/play/res/xml/app_metadata.xml | 4 +- 2 files changed, 200 insertions(+), 17 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 3fc5be7075..ac8f3358f9 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -175,35 +175,146 @@ class AmethystAppFunctions { ): SearchNotesResult { val cappedLimit = limit.coerceIn(1, 200) val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty() - val client = Amethyst.instance.client + val events = fetchFollowFeed(account = account, sinceSecs = null, limit = cappedLimit) + return SearchNotesResult(matches = events.map { it.toNoteHit() }) + } + + /** + * Build a structured digest of the user's Nostr feed for an AI + * summary. Use when the user asks "summarize my Nostr feed", + * "give me a digest of what my follows posted today", "recap + * Nostr for me", "what have people been talking about on Nostr", + * or any "summary / digest / recap of my Nostr timeline" intent. + * + * Returns the raw notes plus pre-extracted signals the LLM needs + * to write a useful summary without re-deriving them: total note + * count, unique author count, top hashtags in the window, and the + * most-mentioned users (with display names resolved from the local + * kind:0 cache). The LLM composes the natural-language summary + * from these. + * + * @param hoursBack window size in hours. Capped to 168 (7 days), + * default 12. + * @param maxNotes max notes returned in the body. Capped to 200. + * Default 60 — big enough for a meaningful summary, small enough + * to fit comfortably in the LLM's prompt. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getFeedDigest( + appFunctionContext: AppFunctionContext, + hoursBack: Int = 12, + maxNotes: Int = 60, + ): FeedDigestResult { + val cappedHours = hoursBack.coerceIn(1, 24 * 7) + val cappedMaxNotes = maxNotes.coerceIn(1, 200) + val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return FeedDigestResult.empty() + + val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L + // Over-fetch a bit so the stats are computed over a larger + // sample than the trimmed `notes` list — the LLM gets richer + // signal without seeing every note. Cap at 500 events to keep + // the on-device work bounded. + val events = + fetchFollowFeed( + account = account, + sinceSecs = sinceSecs, + limit = (cappedMaxNotes * 3).coerceAtMost(500), + ) + + // Hashtag frequencies — case-folded so `#Bitcoin` and + // `#bitcoin` collapse to one bucket. + val hashtagCounts = HashMap() + // Mention frequencies, keyed by mentioned pubkey hex. + val mentionCounts = HashMap() + val uniqueAuthors = HashSet() + + for (ev in events) { + uniqueAuthors.add(ev.pubKey) + for (tag in ev.tags) { + if (tag.size < 2) continue + when (tag[0]) { + "t" -> { + val cleaned = tag[1].trim().removePrefix("#").lowercase() + if (cleaned.isNotEmpty()) { + hashtagCounts.merge(cleaned, 1, Int::plus) + } + } + "p" -> { + // Skip self-mentions (the author tags themself + // in some clients) — not useful for the digest. + if (tag[1].length == 64 && tag[1] != ev.pubKey) { + mentionCounts.merge(tag[1], 1, Int::plus) + } + } + } + } + } + + val topHashtags = + hashtagCounts.entries + .sortedByDescending { it.value } + .take(TOP_HASHTAGS_LIMIT) + .map { HashtagFrequency(tag = it.key, noteCount = it.value) } + + val topMentions = + mentionCounts.entries + .sortedByDescending { it.value } + .take(TOP_MENTIONS_LIMIT) + .map { (pub, count) -> + MentionFrequency( + npub = NPub.create(pub), + pubkeyHex = pub, + displayName = displayNameOf(pub), + mentionCount = count, + ) + } + + return FeedDigestResult( + windowHours = cappedHours, + totalNoteCount = events.size, + uniqueAuthorCount = uniqueAuthors.size, + topHashtags = topHashtags, + topMentions = topMentions, + // Truncate to the caller-requested limit for the body — + // the LLM has the stats either way and doesn't need every + // note quoted. + notes = events.take(cappedMaxNotes).map { it.toNoteHit() }, + ) + } + + /** + * Shared core of [getRecentFromFollows] and [getFeedDigest]: drain + * recent kind:1 notes from the active account's follow set, + * optionally filtered by `since`. Returns empty when there's no + * account, no follows, or no relays configured. + */ + private suspend fun fetchFollowFeed( + account: com.vitorpamplona.amethyst.model.Account, + sinceSecs: Long?, + limit: Int, + ): List { val authors = account.kind3FollowList.flow.value.authors - if (authors.isEmpty()) return SearchNotesResult.empty() + if (authors.isEmpty()) return emptyList() val relays = account.homeRelays.flow.value .ifEmpty { DefaultNIP65RelaySet } - if (relays.isEmpty()) return SearchNotesResult.empty() + if (relays.isEmpty()) return emptyList() val filter = Filter( kinds = listOf(TextNoteEvent.KIND), authors = authors.toList(), - limit = cappedLimit, + since = sinceSecs, + limit = limit, ) - val events = - client.fetchAll( + return Amethyst.instance.client + .fetchAll( filters = relays.associateWith { listOf(filter) }, timeoutMs = GEMINI_FETCH_TIMEOUT_MS, - ) - - val hits = - events - .mapNotNull { it as? TextNoteEvent } - .take(cappedLimit) - .map { it.toNoteHit() } - - return SearchNotesResult(matches = hits) + ).mapNotNull { it as? TextNoteEvent } + .take(limit) } /** @@ -1641,6 +1752,15 @@ class AmethystAppFunctions { * a few seconds; 30s is generous without letting a stuck * wallet stall the dispatch indefinitely. */ private const val NWC_PAYMENT_TIMEOUT_MS = 30_000L + + /** Cap on the number of distinct hashtags surfaced in a feed + * digest. Picked to fit a one-paragraph summary without + * noise — the long tail won't help the LLM. */ + private const val TOP_HASHTAGS_LIMIT = 10 + + /** Cap on the number of mentioned users surfaced in a feed + * digest. Same rationale as TOP_HASHTAGS_LIMIT. */ + private const val TOP_MENTIONS_LIMIT = 10 } } @@ -1676,6 +1796,69 @@ class SearchProfilesResult( } } +/** One hashtag and how many notes in the digest window carried it. + * Lowercased and stripped of the leading `#`. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class HashtagFrequency( + /** The hashtag value without the leading `#`, lowercased. */ + val tag: String, + /** Number of notes in the digest window that carried this tag. */ + val noteCount: Int, +) + +/** One pubkey that was mentioned via `p` tags in the digest window, + * with display name resolved from the local kind:0 cache when known. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class MentionFrequency( + /** Bech32 npub of the mentioned user. */ + val npub: String, + /** Hex pubkey of the mentioned user. */ + val pubkeyHex: String, + /** Best-effort display name from the local kind:0 cache. Null when + * the user's profile hasn't been seen yet — caller falls back to + * the npub. */ + val displayName: String?, + /** Number of notes in the digest window that mention this user. */ + val mentionCount: Int, +) + +/** + * Structured snapshot of the active account's Nostr feed for + * [AmethystAppFunctions.getFeedDigest]. The LLM uses the aggregate + * signals (counts + top hashtags + top mentions) to write a one- or + * two-paragraph summary; the raw [notes] list is included for + * follow-up questions ("which post was about X?"). + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +class FeedDigestResult( + /** Window size in hours actually queried (after capping). */ + val windowHours: Int, + /** Total notes scanned for stats. May exceed [notes].size when the + * body was truncated to fit the LLM prompt. */ + val totalNoteCount: Int, + /** Distinct authors who posted in the window. */ + val uniqueAuthorCount: Int, + /** Top hashtags by note count — at most 10. */ + val topHashtags: List, + /** Most-mentioned users by note count — at most 10. */ + val topMentions: List, + /** Notes themselves (truncated to the caller's maxNotes). Newest- + * first; same fields as [NoteHit] returned by the search verbs. */ + val notes: List, +) { + companion object { + fun empty() = + FeedDigestResult( + windowHours = 0, + totalNoteCount = 0, + uniqueAuthorCount = 0, + topHashtags = emptyList(), + topMentions = emptyList(), + notes = emptyList(), + ) + } +} + /** Single match in [SearchNotesResult]. */ @AppFunctionSerializable(isDescribedByKDoc = true) class NoteHit( diff --git a/amethyst/src/play/res/xml/app_metadata.xml b/amethyst/src/play/res/xml/app_metadata.xml index 20ebd6d298..4ef84570f9 100644 --- a/amethyst/src/play/res/xml/app_metadata.xml +++ b/amethyst/src/play/res/xml/app_metadata.xml @@ -12,5 +12,5 @@ the @AppFunction surface grows. --> + appfn:description="Amethyst is a Nostr social client. The agent can read: search Nostr profiles, notes, hashtags, and long-form articles; look up any user's profile by npub; read recent posts from the signed-in user's follows or any specific user; summarize the feed for a time window (with top hashtags, top mentions, counts pre-extracted for AI digestion); read recent direct messages (decrypted); list NIP-57 zaps received and total sats earned in a time window; surface notes that mention or reply to the user; list currently-live audio/video streams; and report basic account info. The agent can also write: publish short text notes, follow or unfollow other users, send NIP-17 gift-wrapped direct messages, and zap users or specific notes via Lightning (with NWC auto-pay when configured) — provided the user is signed in with a local key or NIP-46 bunker. NIP-55 external signers (Amber) are read-only from the agent for now." + appfn:displayDescription="Read, write, summarize, and zap Nostr through Amethyst" /> From 6671cb3cbcca2c73598d236e7fa725e92955a133 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 16:04:47 +0000 Subject: [PATCH 21/21] =?UTF-8?q?refactor(amethyst):=20getFeedDigest=20now?= =?UTF-8?q?=20uses=20HomeNewThreadFeedFilter=20=E2=80=94=20mirrors=20the?= =?UTF-8?q?=20home=20page=20exactly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: returning "summarize my feed" results that didn't match what's actually on the home page is misleading. Now the verb invokes the same `HomeNewThreadFeedFilter` the foreground UI uses, against the same LocalCache, so the LLM sees what the user would see if they opened Amethyst. What this fixes: * Reposts, polls, long-form, comments, audio, etc. — the home filter accepts ~17 event kinds; the previous verb saw only kind:1. * Muted users — now filtered out. * Replies — excluded (top-level threads only, matching the UI). * Repost dedup — same note via multiple reposts collapses to one entry, as on the screen. * The user's currently-selected NIP-51 follow list (custom lists, hashtag feeds, communities) — now respected. Was previously hardcoded to plain kind:3. Trade-off: reads from LocalCache, so the verb reflects what the foreground has already pulled. If the user hasn't opened Amethyst in a while, the digest is sparse. Acceptable for "summarize what I'm seeing" semantics — for fresh data, the other verbs (search*, getRecentFromFollows) do their own relay drain. Implementation: * Event.toFeedNoteHit() generic projection — handles the broader event range with snippet-truncation for long content. * NoteHit gains a `kind: Int` field so the LLM can distinguish "Alice posted a note" from "Alice published an article" or "Alice ran a poll". * TextNoteEvent.toNoteHit() delegates to the generic helper. * searchArticles' inline NoteHit construction also folds into the generic helper — one less code path to maintain. Plus amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md documenting the broader pattern: every Amethyst screen has a FeedContentState driven by a *FeedFilter; we'd add one AppFunction verb per screen, all going through the same filter pipeline the UI uses. Lists the ~25 unmapped feeds with proposed verb names so the work has a clear roadmap. Same pipeline will back the future MCP server. --- ...026-05-26-appfunctions-screens-as-verbs.md | 191 ++++++++++++++++++ .../appfunctions/AmethystAppFunctions.kt | 128 +++++++----- 2 files changed, 273 insertions(+), 46 deletions(-) create mode 100644 amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md diff --git a/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md b/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md new file mode 100644 index 0000000000..f149d00818 --- /dev/null +++ b/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md @@ -0,0 +1,191 @@ +# All Amethyst screens as AppFunctions / MCP endpoints + +**Date:** 2026-05-26 +**Status:** Active — informs the v1 read-verb surface and guides +future MCP work + +## The principle + +Every screen in Amethyst has a dedicated `FeedContentState` driven by +a `*FeedFilter` that reads from `LocalCache`. The list is in +`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt` +— there are ~30 entries today. + +> Every Amethyst screen → one AppFunction verb. The verb invokes the +> same `*FeedFilter` the screen uses, runs `feed()` against +> `LocalCache`, and projects the result into a Gemini-friendly +> `NoteHit` / `ProfileHit` / etc. + +This keeps the agent surface in sync with what the user sees, with no +duplicate filtering logic. + +## Why this works + +* `*FeedFilter.feed()` is stateless and idempotent — it reads + `LocalCache` (a global singleton) and `account` state. Safe to + invoke from any thread, any process state, no UI lifecycle + required. +* `AccountFeedContentStates` itself is owned by `AccountViewModel`, + but we don't need the precached state — we just need the filter + class. Invoking it on each AppFunction call is acceptable (a few + ms even on large caches). +* The catch: `LocalCache` only contains what the foreground app + subscriptions have already fetched. If the user hasn't opened + Amethyst in days, the cache may be sparse. Acceptable trade-off: + the agent reflects "what's on your screen now", not "what exists + on Nostr right now". For freshness, the user can open the app or + the verb can fall back to a relay drain. + +## Existing verb → feed mapping + +| AppFunction verb | Feed source | Notes | +|---|---|---| +| `getFeedDigest` | `HomeNewThreadFeedFilter` | Matches the home page (new threads only, all kinds, mute-filtered) | +| `getRecentFromFollows` | direct `INostrClient.fetchAll` (kind:1 only) | Pure kind:1 from kind:3 follows. Different shape than home — keeping both: `getRecentFromFollows` is fast / always fresh, `getFeedDigest` is "what's on my screen" | +| `getMyMentions` | direct relay drain | Could move to `NotificationFeedFilter` | +| `getRecentDms` | direct relay drain + decrypt | Could move to `ChatroomListKnownFeedFilter` / `ChatroomListNewFeedFilter` | +| `getLiveStreams` | direct relay drain | Could move to `LiveStreamsFeedFilter` | +| `searchArticles` | direct relay drain (NIP-50) | Read-side only; users already in cache via `ArticlesFeedFilter` could be merged | + +## Unmapped feeds (proposed verbs) + +These all have existing `FeedContentState`s. Adding a verb each is +~30 lines of glue. + +| Screen | FeedContentState | Proposed verb name | User intent | +|---|---|---|---| +| Home — replies | `homeReplies` | `getRecentReplies` | "what conversations am I in?" | +| Home — everything | `homeEverything` | `getEverythingFeed` | "the full firehose of my follows" | +| Home — live | `homeLive` | `getLiveActivityFromFollows` | "what's live from my follows?" | +| Video | `videoFeed` | `getVideoFeed` | "show me Nostr videos" | +| Pictures | `picturesFeed` | `getPictureFeed` | "what photos are people posting?" | +| Shorts | `shortsFeed` | `getShortVideoFeed` | NIP-71 short video | +| Long-form (your follows) | `longsFeed` | `getLongFormFromFollows` | "what articles are my follows publishing?" | +| Long-form (discover) | `discoverReads` | `discoverArticles` | "find interesting Nostr articles" | +| Marketplace | `discoverMarketplace` | `discoverMarketplaceListings` | "what's for sale on Nostr?" | +| Communities (discover) | `discoverCommunities` | `discoverCommunities` | "find Nostr communities" | +| Communities (list) | `communitiesList` | `getMyCommunities` | "communities I'm a member of" | +| Public chats (discover) | `discoverPublicChats` | `discoverPublicChats` | "find Nostr chat channels" | +| Public chats (list) | `publicChatsFeed` | `getMyPublicChats` | "chats I'm in" | +| DVMs | `discoverDVMs` | `discoverDvms` | "what compute services are available?" | +| Follow sets | `discoverFollowSets` | `discoverFollowSets` | "find curated follow lists" | +| Live streams | `liveStreamsFeed` | (replace `getLiveStreams`) | already exists | +| Nests | `nestsFeed` | `getNests` | "audio rooms" | +| Articles (mine + follows) | `articlesFeed` | `getMyArticles` | combined long-form | +| Polls (open) | `openPollsFeed` | `getOpenPolls` | "what should I vote on?" | +| Polls (closed) | `closedPollsFeed` | `getRecentPollResults` | "what did people vote on?" | +| All polls | `pollsFeed` | (combined; less useful as a verb) | — | +| Badges | `badgesFeed` | `getBadges` | "show me my Nostr badges" | +| Software apps | `softwareAppsFeed` | `discoverNostrApps` | "what apps exist on Nostr?" | +| Emoji packs | `browseEmojiSetsFeed` | `discoverEmojiPacks` | "find custom emoji" | +| Follow packs | `followPacksFeed` | `discoverFollowPacks` | "find people to follow by topic" | +| Products | `productsFeed` | `getProductListings` | "what products are listed?" | +| Calendar appointments | `calendarAppointmentsFeed` | `getUpcomingEvents` | "what Nostr events are coming up?" | +| Calendar collections | `calendarCollectionsFeed` | `getEventCollections` | "what conferences are happening?" | +| Notifications (all) | `notifications` | `getRecentNotifications` | "what's happened to me on Nostr?" | +| Notifications (follows) | `notificationsFollowing` | (variant param) | "notifications from follows" | +| Notifications (everyone) | `notificationsEveryone` | (variant param) | "all notifications" | +| Drafts | `drafts` | `getMyDrafts` | "what did I start writing?" | +| Web bookmarks | `webBookmarks` | `getMyBookmarks` | "what did I bookmark?" | + +That's ~25 unmapped feeds. Each verb is a ~30-line wrapper following +the `getFeedDigest` shape — read filter, project to result type, +return. + +## Implementation pattern + +```kotlin +@AppFunction(isDescribedByKDoc = true) +suspend fun getMyBookmarks( + appFunctionContext: AppFunctionContext, + hoursBack: Int = 168, + maxNotes: Int = 50, +): SearchNotesResult { + val account = Amethyst.instance.sessionManager.loggedInAccount() + ?: return SearchNotesResult.empty() + val sinceSecs = TimeUtils.now() - hoursBack.coerceIn(1, 24 * 365).toLong() * 3600L + + val feed = WebBookmarkFeedFilter(account).feed() + .asSequence() + .mapNotNull { it.event } + .filter { it.createdAt >= sinceSecs } + .sortedByDescending { it.createdAt } + .take(maxNotes.coerceIn(1, 200)) + .map { it.toFeedNoteHit() } + .toList() + + return SearchNotesResult(matches = feed) +} +``` + +The pattern is genuinely uniform. Most verbs would even share a +helper like `feedAsResult(filter, sinceHours, max) -> SearchNotesResult`. + +## Result-type strategy + +Most feeds project to `SearchNotesResult` since the screen is "a list +of notes." A few need bespoke types: +* Notifications — could return a `NotificationHit` carrying the + notification kind (reply, mention, zap, repost, reaction) since + the LLM needs to know "you got 3 zaps and 1 reply". +* Calendar events — natural fit for an `EventHit` with start/end + times. +* Communities / public chats — list-of-rooms more than list-of-notes. + +Default to reusing `NoteHit` (now carries `kind`); add bespoke types +only when the LLM needs structure the LLM can't derive from `kind` + +`content`. + +## What doesn't fit cleanly + +Some screens are too interactive for a single AppFunction call: +* **Chats / DMs** — sending and reading a stream of messages is + more conversational; the agent loop should handle it. `sendDm` + + `getRecentDms` cover the basics. +* **Profile pages** — already covered by `getProfile` + + `getNotesByUser` rather than a "profile feed." +* **Settings screens** — out of scope; the agent shouldn't mutate + user prefs. + +## When the cache is cold + +For verbs backed by `LocalCache` (the screens), the result is sparse +when the user hasn't opened the app recently. The mitigation strategy +is: + +1. The relay-drain verbs (`searchProfiles`, `searchNotes`, + `searchByHashtag`, `searchArticles`, `getRecentFromFollows`, + `getNotesByUser`, `getProfile`, `getRecentDms`, + `getZapsReceived`, `getLiveStreams`) all do their own fetch. + Use these when freshness matters. +2. The screen-mirror verbs (`getFeedDigest` and the proposed + additions) reflect what the foreground saw. Use these when + "what was the user looking at?" is the semantic. + +Both shapes have value. The agent's prompt-matching kdoc decides +which gets called. + +## MCP angle + +When we add an MCP server for Amethyst (separate effort), the same +`*FeedFilter.feed()` calls power the MCP tool implementations. The +AppFunctions adapter and the MCP server share the projection +helpers (`toFeedNoteHit`, `toProfileHit`, etc.) and the result +types. The transport layer is the only difference. + +The screen-feed mapping above is the source of truth for both +surfaces. + +## Concrete next steps + +Highest user-value follow-ups (each ~30 min): + +1. `getRecentNotifications` — answers "what's been happening to me + on Nostr?" without a per-kind walk. +2. `getMyBookmarks` — agent recall over saved Nostr content. +3. `getOpenPolls` — "what should I vote on?" +4. `getMyDrafts` — "what was I writing?" +5. `getUpcomingEvents` — calendar / agenda integration. + +After these, the rest are mostly "discover X" variants that follow +the same template. diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index ac8f3358f9..5f5e1b0f22 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.actions.ZapActions import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -181,24 +182,42 @@ class AmethystAppFunctions { } /** - * Build a structured digest of the user's Nostr feed for an AI - * summary. Use when the user asks "summarize my Nostr feed", + * Build a structured digest of the user's Nostr home feed for an + * AI summary. Use when the user asks "summarize my Nostr feed", * "give me a digest of what my follows posted today", "recap * Nostr for me", "what have people been talking about on Nostr", - * or any "summary / digest / recap of my Nostr timeline" intent. + * "summarize what's on my Nostr home screen", or any "summary / + * digest / recap of my Nostr timeline" intent. + * + * **Mirrors the home page exactly.** Runs the same + * `HomeNewThreadFeedFilter` the Amethyst home screen uses against + * the local event cache — so the LLM sees what the user would see + * if they opened the app: short text notes, reposts (deduped), + * long-form articles, polls, comments, audio, classifieds, + * highlights, and the rest. Respects the user's currently selected + * NIP-51 follow list (not just plain kind:3), filters muted users, + * and excludes replies (top-level threads only). + * + * Because the feed is read from the local cache rather than drained + * fresh from relays, the digest reflects what the foreground app + * has previously gathered — sparse if the app hasn't been opened + * recently. Open Amethyst before asking the agent to summarise if + * you want the freshest possible result. * * Returns the raw notes plus pre-extracted signals the LLM needs * to write a useful summary without re-deriving them: total note * count, unique author count, top hashtags in the window, and the - * most-mentioned users (with display names resolved from the local - * kind:0 cache). The LLM composes the natural-language summary - * from these. + * most-mentioned users (display names resolved from local kind:0 + * cache). The LLM composes the natural-language summary from + * these. * * @param hoursBack window size in hours. Capped to 168 (7 days), - * default 12. + * default 12. Events older than this are excluded from both the + * stats and the body. * @param maxNotes max notes returned in the body. Capped to 200. * Default 60 — big enough for a meaningful summary, small enough - * to fit comfortably in the LLM's prompt. + * to fit comfortably in the LLM's prompt. Stats are computed + * over the full in-window set, not just the trimmed body. */ @AppFunction(isDescribedByKDoc = true) suspend fun getFeedDigest( @@ -211,16 +230,19 @@ class AmethystAppFunctions { val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return FeedDigestResult.empty() val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L - // Over-fetch a bit so the stats are computed over a larger - // sample than the trimmed `notes` list — the LLM gets richer - // signal without seeing every note. Cap at 500 events to keep - // the on-device work bounded. - val events = - fetchFollowFeed( - account = account, - sinceSecs = sinceSecs, - limit = (cappedMaxNotes * 3).coerceAtMost(500), - ) + + // Use the same filter the home screen uses, so the digest + // mirrors what the user actually sees in the UI. The filter + // reads from LocalCache (already maintained by the foreground + // subscriptions) and applies the user's selected follow list, + // mutes, repost dedup, and new-thread-only rule. + val feed = + HomeNewThreadFeedFilter(account) + .feed() + .asSequence() + .mapNotNull { it.event } + .filter { it.createdAt >= sinceSecs } + .toList() // Hashtag frequencies — case-folded so `#Bitcoin` and // `#bitcoin` collapse to one bucket. @@ -229,7 +251,7 @@ class AmethystAppFunctions { val mentionCounts = HashMap() val uniqueAuthors = HashSet() - for (ev in events) { + for (ev in feed) { uniqueAuthors.add(ev.pubKey) for (tag in ev.tags) { if (tag.size < 2) continue @@ -272,14 +294,18 @@ class AmethystAppFunctions { return FeedDigestResult( windowHours = cappedHours, - totalNoteCount = events.size, + totalNoteCount = feed.size, uniqueAuthorCount = uniqueAuthors.size, topHashtags = topHashtags, topMentions = topMentions, // Truncate to the caller-requested limit for the body — // the LLM has the stats either way and doesn't need every - // note quoted. - notes = events.take(cappedMaxNotes).map { it.toNoteHit() }, + // note quoted. Sorted newest-first. + notes = + feed + .sortedByDescending { it.createdAt } + .take(cappedMaxNotes) + .map { it.toFeedNoteHit() }, ) } @@ -869,25 +895,7 @@ class AmethystAppFunctions { events .mapNotNull { it as? LongTextNoteEvent } .take(cappedLimit) - .map { ev -> - // Long-form articles can be book-length; cap the - // content payload so the AppFunctions result stays - // bounded — Gemini can ask for a follow-up if needed. - val snippet = - if (ev.content.length > LONG_FORM_SNIPPET_LIMIT) { - ev.content.take(LONG_FORM_SNIPPET_LIMIT) + "…" - } else { - ev.content - } - NoteHit( - eventId = ev.id, - npub = NPub.create(ev.pubKey), - pubkeyHex = ev.pubKey, - authorDisplayName = displayNameOf(ev.pubKey), - createdAt = ev.createdAt, - content = snippet, - ) - } + .map { (it as com.vitorpamplona.quartz.nip01Core.core.Event).toFeedNoteHit() } return SearchNotesResult(matches = hits) } @@ -1596,15 +1604,36 @@ class AmethystAppFunctions { ?.metadataOrNull() ?.bestName() - private fun TextNoteEvent.toNoteHit(): NoteHit = - NoteHit( + private fun TextNoteEvent.toNoteHit(): NoteHit = (this as com.vitorpamplona.quartz.nip01Core.core.Event).toFeedNoteHit() + + /** + * Project any home-feed-eligible event into a [NoteHit]. Covers + * the broader event set the home filter accepts (kind:1, kind:6 + * reposts, kind:30023 long-form, polls, comments, etc.), so a + * digest can carry whatever the user actually sees. + * + * Long content is snippet-truncated so a book-length article + * doesn't blow up the AppFunctions response — Gemini can ask the + * user whether to fetch the full article through a follow-up + * verb call. + */ + private fun com.vitorpamplona.quartz.nip01Core.core.Event.toFeedNoteHit(): NoteHit { + val snippet = + if (content.length > LONG_FORM_SNIPPET_LIMIT) { + content.take(LONG_FORM_SNIPPET_LIMIT) + "…" + } else { + content + } + return NoteHit( eventId = id, + kind = kind, npub = NPub.create(pubKey), pubkeyHex = pubKey, authorDisplayName = displayNameOf(pubKey), createdAt = createdAt, - content = content, + content = snippet, ) + } private fun MetadataEvent.toProfileHit(): ProfileHit { val meta = contactMetaData() @@ -1859,11 +1888,16 @@ class FeedDigestResult( } } -/** Single match in [SearchNotesResult]. */ +/** Single match in [SearchNotesResult] or entry in a feed result. */ @AppFunctionSerializable(isDescribedByKDoc = true) class NoteHit( /** Hex event id of the note. */ val eventId: String, + /** Nostr event kind. 1 = short text note, 6 = repost, 30023 = + * long-form article, 1111 = comment, 9802 = highlight, 1068 = + * poll, etc. Lets the LLM distinguish "Alice posted a note" + * from "Alice published an article" or "Alice ran a poll". */ + val kind: Int, /** Bech32 npub of the note's author. */ val npub: String, /** Hex pubkey of the note's author. */ @@ -1874,7 +1908,9 @@ class NoteHit( val authorDisplayName: String?, /** Unix-seconds timestamp the note was created at. */ val createdAt: Long, - /** Raw content of the note (plain text, may contain Nostr URIs / hashtags). */ + /** Raw content of the note (plain text, may contain Nostr URIs / + * hashtags). Truncated at ~2000 chars for very long content; the + * full event is reachable by its [eventId] via a follow-up verb. */ val content: String, )