mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3483 from nrobi144/feat/wot-shared-index-relays
feat(desktop): Web-of-Trust score badges + shared index relays + amy wot verbs
This commit is contained in:
@@ -448,6 +448,23 @@ class Context(
|
||||
/** Union of all three buckets. */
|
||||
suspend fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
|
||||
|
||||
/**
|
||||
* Index relays — the shared, app-global set used to fetch profile
|
||||
* metadata (kind 0) and follow lists (kind 3). Mirrors the Desktop
|
||||
* app's `LocalRelayCategories.indexRelays` by reading from the same
|
||||
* `java.util.prefs` node
|
||||
* (`com/vitorpamplona/amethyst/relays/index`). Falls back to the
|
||||
* shipping defaults when the user hasn't configured anything.
|
||||
*
|
||||
* This is what `amy wot sync` uses; `outboxRelays()` /
|
||||
* `inboxRelays()` remain for callers that want relay lists derived
|
||||
* from NIP-65 identity semantics.
|
||||
*/
|
||||
fun indexRelays(): Set<NormalizedRelayUrl> =
|
||||
com.vitorpamplona.amethyst.commons.relays.index
|
||||
.PreferencesIndexRelays()
|
||||
.effective()
|
||||
|
||||
/**
|
||||
* Seed relays for "look up someone we know nothing about" queries —
|
||||
* fetching another user's kind:10002 / 10050 / 10051 / 30443 before we
|
||||
|
||||
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.SyncCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.UseCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.VerifyCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.WotCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.ZapCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands
|
||||
@@ -287,6 +288,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"podcast" -> PodcastCommands.dispatch(dataDir, tail)
|
||||
"podcast20" -> Podcast20Commands.dispatch(dataDir, tail)
|
||||
"bunker" -> BunkerCommand.run(dataDir, tail)
|
||||
"wot" -> WotCommand.dispatch(dataDir, tail)
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
printUsage()
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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.wot.OutboxCacheGateway
|
||||
import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher
|
||||
import com.vitorpamplona.amethyst.commons.wot.WoTService
|
||||
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.nip65RelayList.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* `amy wot <get|list|sync>` — Web-of-Trust score queries.
|
||||
*
|
||||
* The score for a pubkey X is the count of accounts in the active user's
|
||||
* kind-3 follow set who also follow X. `get` and `list` are read-only —
|
||||
* they hydrate the score map from whatever kind-3 events already live in
|
||||
* the local event store. `sync` pulls fresh kind-3 events from the
|
||||
* configured relay pool so the next `get` / `list` is up to date.
|
||||
*/
|
||||
object WotCommand {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val head = rest.firstOrNull() ?: return usage()
|
||||
val tail = rest.drop(1).toTypedArray()
|
||||
return when (head) {
|
||||
"get" -> get(dataDir, tail)
|
||||
"list" -> list(dataDir, tail)
|
||||
"sync" -> sync(dataDir, tail)
|
||||
else -> usage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun usage(): Int = Output.error("bad_args", "wot <get|list|sync>")
|
||||
|
||||
private suspend fun get(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "wot get <pubkey|npub>")
|
||||
val userArg = rest[0]
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val target = ctx.requireUserHex(userArg)
|
||||
val (svc, scope) = buildHydratedService(ctx)
|
||||
try {
|
||||
val score = svc.scoresSnapshot()[target] ?: 0
|
||||
Output.emit(mapOf("pubkey" to target, "score" to score))
|
||||
return 0
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun list(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val threshold = args.flag("threshold")?.toIntOrNull() ?: 1
|
||||
val limit = args.flag("limit")?.toIntOrNull() ?: 50
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val (svc, scope) = buildHydratedService(ctx)
|
||||
try {
|
||||
val entries =
|
||||
svc
|
||||
.scoresSnapshot()
|
||||
.entries
|
||||
.asSequence()
|
||||
.filter { it.value >= threshold }
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { mapOf("pubkey" to it.key, "score" to it.value) }
|
||||
.toList()
|
||||
Output.emit(mapOf("count" to entries.size, "entries" to entries))
|
||||
return 0
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sync(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
// Overall timeout; per-relay budget is set by OutboxDispatcher's
|
||||
// default (4s). `--timeout N` overrides the overall cap.
|
||||
val overallTimeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 8_000L
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val self = ctx.identity.pubKeyHex
|
||||
val myKind3 = ctx.contactsOf(self)
|
||||
val follows =
|
||||
myKind3?.verifiedFollowKeySet()?.toSet()
|
||||
?: return Output.error("no_follows", "no kind-3 in local store; run `amy follow` first")
|
||||
if (follows.isEmpty()) {
|
||||
Output.emit(mapOf("synced" to 0, "detail" to "empty follow set"))
|
||||
return 0
|
||||
}
|
||||
val relays = ctx.indexRelays()
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no index relays configured")
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
try {
|
||||
// Buffer discovered events; persist synchronously after
|
||||
// the fetch. `store.insert` is suspending so we can't call
|
||||
// it from the non-suspending gateway callbacks. This also
|
||||
// keeps `insert` errors surfaceable in a single log line
|
||||
// rather than swallowed into a race.
|
||||
val buffered = Collections.synchronizedList(mutableListOf<Event>())
|
||||
val gateway =
|
||||
object : OutboxCacheGateway {
|
||||
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? =
|
||||
// Amy's store lookup is suspending; can't do
|
||||
// it here. The dispatcher then falls through
|
||||
// to Phase 1 discovery for every author, which
|
||||
// matches the old `amy wot sync` behaviour of
|
||||
// always re-asking. A future optimisation
|
||||
// could pre-populate a `Map<HexKey,
|
||||
// AdvertisedRelayListEvent>` before dispatch.
|
||||
null
|
||||
|
||||
override fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
buffered.add(event)
|
||||
}
|
||||
|
||||
override fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
buffered.add(event)
|
||||
}
|
||||
}
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = ctx.client,
|
||||
scope = scope,
|
||||
indexRelays = { relays },
|
||||
gateway = gateway,
|
||||
overallTimeoutMs = overallTimeoutMs,
|
||||
)
|
||||
|
||||
val result = dispatcher.fetchKind3Only(follows)
|
||||
|
||||
// Persist to store so future `get` / `list` see them.
|
||||
val eventsToPersist = synchronized(buffered) { buffered.toList() }
|
||||
eventsToPersist.forEach { runCatching { ctx.store.insert(it) } }
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"followers" to follows.size,
|
||||
"authors_requested" to result.authorsRequested,
|
||||
"kind10002_received" to result.kind10002Received,
|
||||
"kind3_received" to result.kind3Received,
|
||||
"outbox_covered_authors" to result.outboxCoveredAuthors,
|
||||
"fallback_authors" to result.fallbackAuthors,
|
||||
"persisted" to eventsToPersist.size,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a [WoTService], populate it from the local event store, then
|
||||
* return the (service, backing scope). Caller must cancel the scope
|
||||
* when done.
|
||||
*/
|
||||
private suspend fun buildHydratedService(ctx: Context): Pair<WoTService, CoroutineScope> {
|
||||
val self = ctx.identity.pubKeyHex
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
val svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined)
|
||||
val myKind3 = ctx.contactsOf(self)
|
||||
val follows: Set<HexKey> = myKind3?.verifiedFollowKeySet() ?: emptySet()
|
||||
svc.onFollowSetChange(follows, self)
|
||||
// Pull each follower's kind-3 from the store and feed into the service.
|
||||
follows.forEach { follower ->
|
||||
val followerKind3 = ctx.contactsOf(follower) ?: return@forEach
|
||||
svc.applyKind3(follower, followerKind3.verifiedFollowKeySet())
|
||||
}
|
||||
svc.markReadyOnce()
|
||||
return svc to scope
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user