Merge pull request #3051 from vitorpamplona/claude/brave-clarke-hJ0PK

Phase 2: Gemini AppFunctions bridge + CLI action verbs
This commit is contained in:
Vitor Pamplona
2026-05-26 12:23:57 -04:00
committed by GitHub
26 changed files with 5655 additions and 91 deletions
+22
View File
@@ -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)
@@ -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.
@@ -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.
@@ -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.
+17
View File
@@ -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" />
<!-- Gemini / system-agent bridge via androidx.appfunctions
(pre-stable as of May 2026). The androidx-provided
PlatformAppFunctionService (registered automatically via
manifest merging by the library) dispatches to plain Kotlin
classes annotated with @AppFunction (see
com.vitorpamplona.amethyst.appfunctions.AmethystAppFunctions).
Play flavor only — the F-Droid channel ships without the
alpha Google AI dependency.
The `app_metadata` property below gives the system agent a
user-facing summary of what this app exposes. Without it,
system logcat logs "Unable to resolve AppFunctionMetadata"
and our functions never make it into Gemini's tool picker. -->
<property
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
</application>
</manifest>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Natural-language description of the app for the AppFunctions agent
(Gemini and other system assistants). Resolved by the system via the
application-level `<property android:name="android.app.appfunctions.app_metadata">`
pointer in play/AndroidManifest.xml. Without this resource, system
logcat shows "Unable to resolve AppFunctionMetadata" and the agent
can't render a user-facing summary of what our functions do.
Keep descriptions short and Nostr-grounded so a user reading "What
can Amethyst do?" in the agent gets a useful answer. Update when
the @AppFunction surface grows.
-->
<AppFunctionAppMetadata xmlns:appfn="http://schemas.android.com/apk/res-auto"
appfn:description="Amethyst is a Nostr social client. The agent can read: search Nostr profiles, notes, hashtags, and long-form articles; look up any user's profile by npub; read recent posts from the signed-in user's follows or any specific user; summarize the feed for a time window (with top hashtags, top mentions, counts pre-extracted for AI digestion); read recent direct messages (decrypted); list NIP-57 zaps received and total sats earned in a time window; surface notes that mention or reply to the user; list currently-live audio/video streams; and report basic account info. The agent can also write: publish short text notes, follow or unfollow other users, send NIP-17 gift-wrapped direct messages, and zap users or specific notes via Lightning (with NWC auto-pay when configured) — provided the user is signed in with a local key or NIP-46 bunker. NIP-55 external signers (Amber) are read-only from the agent for now."
appfn:displayDescription="Read, write, summarize, and zap Nostr through Amethyst" />
+1
View File
@@ -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.
@@ -159,6 +159,22 @@ private suspend fun dispatch(argv: Array<String>): 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)
@@ -95,4 +95,24 @@ object Commands {
dataDir: DataDir,
tail: Array<String>,
): Int = StoreCommands.dispatch(dataDir, tail)
suspend fun follow(
dataDir: DataDir,
tail: Array<String>,
): Int = FollowCommand.follow(dataDir, tail)
suspend fun unfollow(
dataDir: DataDir,
tail: Array<String>,
): Int = FollowCommand.unfollow(dataDir, tail)
suspend fun search(
dataDir: DataDir,
tail: Array<String>,
): Int = SearchCommand.dispatch(dataDir, tail)
suspend fun zap(
dataDir: DataDir,
tail: Array<String>,
): Int = ZapCommand.dispatch(dataDir, tail)
}
@@ -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<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
): Pair<NIP17Factory.Result, Map<String, Any?>>? {
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<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
): Pair<NIP17Factory.Result, Map<String, Any?>>? {
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<NormalizedRelayUrl>,
val source: String,
)
private sealed interface DecryptedDm {
val id: HexKey
val wrapId: HexKey
@@ -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 <user>` and `amy unfollow <user>` — 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 `<user>`: npub / nprofile / 64-hex /
* `name@domain.tld` — same set [Context.requireUserHex] handles.
*/
object FollowCommand {
suspend fun follow(
dataDir: DataDir,
rest: Array<String>,
): Int = run(dataDir, rest, FollowOp.FOLLOW)
suspend fun unfollow(
dataDir: DataDir,
rest: Array<String>,
): Int = run(dataDir, rest, FollowOp.UNFOLLOW)
private enum class FollowOp { FOLLOW, UNFOLLOW }
private suspend fun run(
dataDir: DataDir,
rest: Array<String>,
op: FollowOp,
): Int {
if (rest.isEmpty()) {
val verb = if (op == FollowOp.FOLLOW) "follow" else "unfollow"
return Output.error("bad_args", "$verb <user> [--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<NormalizedRelayUrl>,
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 }
}
}
@@ -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 <user|note> <query>` — 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 <query>` 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 <query>` 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<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "search <user|note> <query> [--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<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "search user <query> [--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<String, Any?>()),
)
}
}
}
private suspend fun searchNotes(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "search note <query> [--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<Event>) -> List<Map<String, Any?>>,
): 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<Event>(
Filter(
authors = listOf(ctx.identity.pubKeyHex),
kinds = listOf(SearchRelayListEvent.KIND),
limit = 1,
),
).firstOrNull() as? SearchRelayListEvent
}
@@ -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 <user|event> <target> <sats>` — build a NIP-57 zap request and
* fetch a BOLT11 invoice from the recipient's Lightning service.
*
* Two subcommands:
* * `zap user <user> <sats>` — profile zap (no event reference)
* * `zap event <event-id> <sats>` — 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<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "zap <user|event> <target> <sats> [--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<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap user <user> <sats> [--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<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--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<Event>(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<NormalizedRelayUrl> = { 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<ZapActions.ZapRequestForSplit>,
) {
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<String, Any?>(
"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<NormalizedRelayUrl>,
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()
}
@@ -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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
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)
}
}
@@ -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<Pair<HexKey, NormalizedRelayUrl?>>,
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
}
}
@@ -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<Int> = 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<Int> = 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<NormalizedRelayUrl> = DefaultSearchRelayList,
): Set<NormalizedRelayUrl> {
if (currentList == null) return fallback.toSet()
val combined = currentList.relays(signer)
return if (combined.isEmpty()) fallback.toSet() else combined.toSet()
}
}
@@ -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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
lookupLnAddress: suspend (HexKey) -> String?,
lookupInboxRelays: suspend (HexKey) -> Set<NormalizedRelayUrl> = { emptySet() },
comment: String = "",
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
pollOption: Int? = null,
): List<ZapRequestForSplit> {
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)
}
}
}
@@ -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<Recipient> {
val splits = zappedEvent.zapSplitSetup()
val raw: List<Recipient?> =
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()
}
}
@@ -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<User>): ContactListEvent {
val contactList = getFollowListEvent()
suspend fun follow(users: List<User>): 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 {
@@ -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(),
)
}
}
@@ -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())
}
}
@@ -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)
}
}
@@ -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<com.vitorpamplona.quartz.nip10Notes.TextNoteEvent>(
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<com.vitorpamplona.quartz.nip10Notes.TextNoteEvent>(
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<com.vitorpamplona.quartz.nip10Notes.TextNoteEvent>(
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<com.vitorpamplona.quartz.nip10Notes.TextNoteEvent>(
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())
}
}
@@ -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<String>): Event =
authorSigner.sign<TextNoteEvent>(
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())
}
}
+10
View File
@@ -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" }