diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 7bf0cda845..1a0a0471c6 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 = @@ -271,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" @@ -413,6 +426,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/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/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/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/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index aafa18bfb6..60c2dfb7a1 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -38,6 +38,23 @@ android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:value="com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider" /> + + + \ No newline at end of file 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..5f5e1b0f22 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -0,0 +1,2292 @@ +/* + * 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.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.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 +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.relay.normalizer.NormalizedRelayUrl +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 +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 + * 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 (see + * `amethyst/build/generated/ksp/playDebug/.../$AmethystAppFunctions_AppFunctionInvoker.kt`). + * The generated invoker constructs this class via its default no-arg + * constructor, so no `AppFunctionConfiguration.Provider` is required on + * the Application. If we ever add an @AppFunction host class with + * constructor parameters, we'll need to register a factory via Provider — + * the docs nudge that direction, but the runtime does not require it for + * default-constructed classes. + * + * 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 + * 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 { + /** + * 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. + */ + @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() + + // Snapshot the active account + relay set + client at function entry + // and never touch sessionManager again from this dispatch. If the + // 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() + val client = Amethyst.instance.client + + // 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() + + // 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 + .mapNotNull { it as? MetadataEvent } + .distinctBy { it.pubKey } + .sortedByDescending { it.createdAt } + .take(cappedLimit) + .map { it.toProfileHit() } + + return SearchProfilesResult(matches = hits) + } + + /** + * 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. + * + * 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. + */ + @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 events = fetchFollowFeed(account = account, sinceSecs = null, limit = cappedLimit) + return SearchNotesResult(matches = events.map { it.toNoteHit() }) + } + + /** + * 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", + * "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 (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. 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. Stats are computed + * over the full in-window set, not just the trimmed body. + */ + @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 + + // 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. + val hashtagCounts = HashMap() + // Mention frequencies, keyed by mentioned pubkey hex. + val mentionCounts = HashMap() + val uniqueAuthors = HashSet() + + for (ev in feed) { + 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 = 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. Sorted newest-first. + notes = + feed + .sortedByDescending { it.createdAt } + .take(cappedMaxNotes) + .map { it.toFeedNoteHit() }, + ) + } + + /** + * 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 emptyList() + + val relays = + account.homeRelays.flow.value + .ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return emptyList() + + val filter = + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = authors.toList(), + since = sinceSecs, + limit = limit, + ) + return Amethyst.instance.client + .fetchAll( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = GEMINI_FETCH_TIMEOUT_MS, + ).mapNotNull { it as? TextNoteEvent } + .take(limit) + } + + /** + * 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. + * + * 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. + */ + @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) + } + + /** + * 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. + */ + @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) { + val info = cached.flow.value?.info + return GetProfileResult( + found = true, + profile = + ProfileHit( + npub = NPub.create(pubkey), + pubkeyHex = pubkey, + displayName = cached.bestName(), + about = info?.about, + 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 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. + */ + @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) + } + + /** + * 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 { + 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, + ) + } + + /** + * 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) + } + + /** + * 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. + * + * 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. + */ + @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, + ) + } + + /** + * 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 + * 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. + */ + @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, + fromDisplayName = displayNameOf(inner.pubKey), + sentByMe = inner.pubKey == myPub, + 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 { (it as com.vitorpamplona.quartz.nip01Core.core.Event).toFeedNoteHit() } + + 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(), + streamingUrl = ev.streaming(), + hostNpub = hostPub?.let { NPub.create(it) }, + hostPubkeyHex = hostPub, + hostDisplayName = hostPub?.let { displayNameOf(it) }, + startsAt = ev.starts(), + createdAt = ev.createdAt, + ) + } + + return LiveStreamsResult(streams = streams) + } + + // ------------------------------------------------------------------ + // Write verbs — gated on a signer that can sign without launching a + // foreground activity. See requireInProcessSigner below. + // ------------------------------------------------------------------ + + /** + * 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 + * 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) + } + + /** + * 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. [WriteResult.changed] reports + * `false` when the user is already followed. + * + * @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) + } + + /** + * 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. + */ + @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) + } + + /** + * 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. + * + * 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 + * 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, + ) + } + + /** + * 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, 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. + * + * @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) + + // 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, + recipientDisplayName = displayNameOf(recipientPub), + lnAddress = lnAddress, + amountSats = cappedSats, + comment = trimmedComment, + invoice = invoice, + zapRequestId = zapRequest.id, + nwcAttempted = nwc != null, + nwcPaid = nwc?.success == true, + nwcPreimage = nwc?.preimage, + nwcError = nwc?.errorMessage, + ) + } + + /** + * 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. 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] / + * [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 invoiceResult = + runCatching { + fetchInvoiceOrThrow( + lnAddress = req.recipient.lnAddress, + sats = shareSats, + comment = trimmedComment, + 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, + recipientDisplayName = req.recipient.pubkey?.let { displayNameOf(it) }, + lnAddress = req.recipient.lnAddress, + weight = req.recipient.weight, + amountSats = shareSats, + invoice = invoice, + invoiceError = invoiceResult.exceptionOrNull()?.message, + zapRequestId = req.request.id, + nwcAttempted = nwc != null, + nwcPaid = nwc?.success == true, + nwcPreimage = nwc?.preimage, + nwcError = nwc?.errorMessage, + ) + } + + 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() + } + + /** 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, " + + "or returned a malformed reply we couldn't decrypt", + ) + + 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", + ) + 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 + * 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 + * 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 + * [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.", + ) + } + + /** + * 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 = (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 = snippet, + ) + } + + 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(), + ) + } + + /** + * 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 { it.toNoteHit() } + + 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 + * foreground requests in the Gemini UI — anything beyond a few seconds + * 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 + + /** + * 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 + + /** 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 + + /** 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 + + /** 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 + } +} + +/** + * 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()) + } +} + +/** 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] 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. */ + 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). Truncated at ~2000 chars for very long content; the + * full event is reachable by its [eventId] via a follow-up verb. */ + 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()) + } +} + +/** + * 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, + ), + ) + } +} + +/** + * 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, + /** 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. */ + 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?, + /** 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. */ + 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]. + */ +@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, + ) + } +} + +/** + * 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, +) + +/** + * 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( + /** 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 — 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. 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( + /** 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, + /** 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?, +) + +/** + * 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, +) 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..4ef84570f9 --- /dev/null +++ b/amethyst/src/play/res/xml/app_metadata.xml @@ -0,0 +1,16 @@ + + + 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/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index db8818f3d7..6d9c0d8fec 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,22 @@ private suspend fun dispatch(argv: Array): Int { Commands.store(dataDir, tail) } + "follow" -> { + Commands.follow(dataDir, tail) + } + + "unfollow" -> { + Commands.unfollow(dataDir, tail) + } + + "search" -> { + Commands.search(dataDir, tail) + } + + "zap" -> { + Commands.zap(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -327,6 +343,28 @@ 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) + | + |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] + | 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 83a111bd24..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 @@ -95,4 +95,24 @@ 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) + + suspend fun search( + 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/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/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..c96234c8f4 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt @@ -0,0 +1,176 @@ +/* + * 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 + + // 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 -> + FollowActions.buildFollow( + signer = ctx.signer, + pubkeyToFollow = target, + currentContactList = latest, + relayHint = targetRelayHint, + ) + 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/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..0c52732a96 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt @@ -0,0 +1,195 @@ +/* + * 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 } + // 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 { + 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/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..9d864840a6 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -0,0 +1,318 @@ +/* + * 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] [--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 = + 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 bootstrap = ctx.bootstrapRelays() + + // 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, + totalAmountMillisats = ZapActions.satsToMillisats(sats), + senderInboxRelays = ctx.outboxRelays(), + lookupLnAddress = lookupLnAddress, + lookupInboxRelays = lookupInboxRelays, + comment = comment, + zapType = zapType, + ) + + 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() + } + } + + 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) + } + } + } + + /** + * 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 + 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/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/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..76ca908515 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowActions.kt @@ -0,0 +1,124 @@ +/* + * 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. 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 + * 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/actions/SearchActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt new file mode 100644 index 0000000000..2fb16fd2be --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/SearchActions.kt @@ -0,0 +1,123 @@ +/* + * 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. + * + * 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: + * + * * **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. */ + 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/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..5c6bd6ecc9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt @@ -0,0 +1,206 @@ +/* + * 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. + * + * **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 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`. + * + * **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, + 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, + ) + + /** + * 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/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt index 3d3aa45178..d15c13f040 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 @@ -113,52 +113,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/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(), + ) + } +} 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()) + } +} 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) + } +} 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..eaa21809b1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt @@ -0,0 +1,379 @@ +/* + * 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") + } + + // ------------------------------------------------------------------ + // 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()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fff496a4cb..96e081ecaf 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" } @@ -214,3 +223,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" }