mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge pull request #3051 from vitorpamplona/claude/brave-clarke-hJ0PK
Phase 2: Gemini AppFunctions bridge + CLI action verbs
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
+2292
File diff suppressed because it is too large
Load Diff
@@ -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" />
|
||||
@@ -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()
|
||||
}
|
||||
+171
@@ -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)
|
||||
}
|
||||
}
|
||||
+124
@@ -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
|
||||
}
|
||||
}
|
||||
+123
@@ -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()
|
||||
}
|
||||
}
|
||||
+206
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -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()
|
||||
}
|
||||
}
|
||||
+20
-45
@@ -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 {
|
||||
|
||||
+214
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+143
@@ -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())
|
||||
}
|
||||
}
|
||||
+143
@@ -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)
|
||||
}
|
||||
}
|
||||
+379
@@ -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())
|
||||
}
|
||||
}
|
||||
+236
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user