From 5efb5d90e51110b29e239ee6f29a2eb84249a1c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:01:13 +0000 Subject: [PATCH] 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, +)