mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779
# Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt # cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
This commit is contained in:
+9
@@ -43,6 +43,15 @@ fun relayAdvertisesNip(
|
||||
/** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */
|
||||
fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29")
|
||||
|
||||
/**
|
||||
* Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc
|
||||
* resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field
|
||||
* NIP-29 relays publish so clients can verify their relay-signed group metadata — see
|
||||
* [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading,
|
||||
* or the fetch failed), so it never triggers the warning.
|
||||
*/
|
||||
fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null
|
||||
|
||||
/**
|
||||
* Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29:
|
||||
* "these are addressable events signed by the relay keypair directly … as stated by the NIP-11
|
||||
|
||||
+2
-1
@@ -90,7 +90,8 @@ fun NoteHeaderFirstRowDensityPreview() {
|
||||
val nav = EmptyNav()
|
||||
|
||||
// The jump-to-parent arrow only renders in complete UI mode.
|
||||
accountViewModel.settings.uiSettingsFlow.featureSet.value = FeatureSetType.COMPLETE
|
||||
accountViewModel.settings.uiSettingsFlow.featureSet
|
||||
.tryEmit(FeatureSetType.COMPLETE)
|
||||
|
||||
// Let DisplayLocation resolve the geohash synchronously from the cache.
|
||||
CachedReversedGeoLocations.locationNames.put(GEOHASH, "Belo Horizonte")
|
||||
|
||||
+12
-2
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
@@ -139,11 +141,19 @@ fun RelayGroupChannelListScreen(
|
||||
) { padding ->
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
if (channels.isEmpty()) {
|
||||
// An empty directory on a relay whose NIP-11 says it doesn't run NIP-29 is almost
|
||||
// certainly the wrong relay, not a young one — say so instead of the generic empty text.
|
||||
val notNip29 = looksLikeNonNip29Relay(relayInfo)
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_channels_empty),
|
||||
text =
|
||||
if (notNip29) {
|
||||
stringRes(R.string.relay_group_channels_not_nip29)
|
||||
} else {
|
||||
stringRes(R.string.relay_group_channels_empty)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = if (notNip29) MaterialTheme.colorScheme.warningColor else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
+27
@@ -35,11 +35,16 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
|
||||
@@ -54,6 +59,7 @@ fun RelayGroupServerRow(
|
||||
val host = relay?.displayUrl() ?: relayUrl
|
||||
val info = relay?.let { loadRelayInfo(it) }
|
||||
val name = info?.value?.name?.takeIf { it.isNotBlank() } ?: host
|
||||
val missingNip29 = info?.value?.let { looksLikeNonNip29Relay(it) } == true
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
@@ -70,6 +76,7 @@ fun RelayGroupServerRow(
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
pingInMs = 0,
|
||||
iconModifier = LargeRelayIconModifier,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
@@ -87,6 +94,26 @@ fun RelayGroupServerRow(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (missingNip29) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.warningColor,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_relay_not_nip29),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.warningColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
|
||||
+12
-1
@@ -394,9 +394,20 @@ private fun RelayGroupRoomCompose(
|
||||
stringRes(R.string.relay_group_no_messages_yet)
|
||||
}
|
||||
|
||||
val groupPicture = channel.profilePicture()?.ifBlank { null }
|
||||
val channelPicture =
|
||||
if (groupPicture != null) {
|
||||
groupPicture
|
||||
} else {
|
||||
// Missing/blank group picture: fall back to the host relay's NIP-11 icon
|
||||
// (loadRelayInfo fetches the doc on a cache miss).
|
||||
val relayInfo by loadRelayInfo(channel.groupId.relayUrl)
|
||||
relayInfo.icon?.ifBlank { null }
|
||||
}
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = channel.groupId.id,
|
||||
channelPicture = channel.profilePicture(),
|
||||
channelPicture = channelPicture,
|
||||
channelTitle = { modifier ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
|
||||
+9
-3
@@ -138,9 +138,15 @@ class NotificationFeedFilter(
|
||||
setOf(
|
||||
BadgeAwardEvent.KIND,
|
||||
ChannelMessageEvent.KIND,
|
||||
// kind-9 chat message (NIP-C7 / Concord): notifies only when it p-tags me — an inline
|
||||
// reply (Concord's default reply mode) or an @-mention. A minichat reply is a kind-1111
|
||||
// CommentEvent below; a plain channel message tags no one and never reaches here.
|
||||
// kind-9 chat message, shared by two features:
|
||||
// • NIP-29 group chat: a reply to my group message is a kind-9 that p-tags me
|
||||
// (see ChannelNewMessageViewModel), fetched at startup by
|
||||
// filterGroupNotificationsToPubkey.
|
||||
// • NIP-C7 / Concord: an inline reply (Concord's default reply mode) or an @-mention
|
||||
// p-tags me; a minichat reply is a kind-1111 CommentEvent below.
|
||||
// Either way it notifies only when it p-tags me — a plain channel message tags no one
|
||||
// and never reaches here. Without kind 9 the acceptableEvent kind gate would drop these
|
||||
// replies before the p-tag check, so they'd never render on the Notifications tab.
|
||||
ChatEvent.KIND,
|
||||
ChatMessageEvent.KIND,
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND,
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.ui.screen.loggedIn.publicChats
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
|
||||
@Composable
|
||||
fun NewPublicChatButton(nav: INav) {
|
||||
FloatingActionButton(
|
||||
onClick = { nav.nav(Route.ChannelMetadataEdit()) },
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = stringRes(id = R.string.new_public_chat),
|
||||
modifier = Size26Modifier,
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -73,6 +74,11 @@ fun PublicChatsScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
FabBottomBarPadded(nav) {
|
||||
NewPublicChatButton(nav)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableBox(publicChatsFeedContentState, true) {
|
||||
|
||||
@@ -3174,6 +3174,8 @@
|
||||
<string name="playback_actions_dialog_title">播放</string>
|
||||
<string name="video_quality_auto">自动</string>
|
||||
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
|
||||
<string name="edit_nickname">编辑昵称</string>
|
||||
<string name="nickname_private">仅对您可见</string>
|
||||
<!-- LAN cast (Chromecast) feature -->
|
||||
<string name="cast_to_device">投射到设备</string>
|
||||
<string name="cast_stop_casting">停止投影</string>
|
||||
|
||||
@@ -412,6 +412,7 @@
|
||||
and thus chat messages disappear over time</string>
|
||||
|
||||
<string name="public_chat">Public Chat</string>
|
||||
<string name="new_public_chat">Create a new public chat</string>
|
||||
<string name="marmot_group">MLS Group</string>
|
||||
<string name="marmot_group_no_messages_yet">No messages yet</string>
|
||||
<string name="public_chat_title">Public Chat Metadata</string>
|
||||
@@ -2095,6 +2096,8 @@
|
||||
<string name="relay_group_threads_title">Threads</string>
|
||||
<string name="relay_group_open">Open group</string>
|
||||
<string name="relay_group_channels_empty">No groups on this relay yet.</string>
|
||||
<string name="relay_group_channels_not_nip29">This relay does not advertise support for groups (NIP-29), so it may not host any.</string>
|
||||
<string name="relay_group_relay_not_nip29">May not support groups (NIP-29)</string>
|
||||
<string name="relay_group_invite_preparing">Preparing invite…</string>
|
||||
<string name="relay_group_badge_private">Private</string>
|
||||
<string name="relay_group_badge_invite_only">Invite-only</string>
|
||||
|
||||
+20
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal
|
||||
import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -73,4 +74,23 @@ class NotificationKindsContractTest {
|
||||
unaccounted.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A reply to my message inside a NIP-29 relay group is a kind-9 [ChatEvent]
|
||||
* that p-tags me. It is fetched at startup by `filterGroupNotificationsToPubkey`
|
||||
* (scoped `#p`=me + `#h`=my groups on the group's host relay), but the
|
||||
* `acceptableEvent` gate first checks `kind in NOTIFICATION_KINDS`, so without
|
||||
* kind 9 in the set the reply is dropped before the p-tag check and never
|
||||
* surfaces on the Notifications tab. Pin its presence so it can't silently
|
||||
* regress.
|
||||
*/
|
||||
@Test
|
||||
fun `nip-29 group chat replies render on the Android notifications tab`() {
|
||||
assertTrue(
|
||||
"ChatEvent.KIND (9) is missing from NOTIFICATION_KINDS. NIP-29 group " +
|
||||
"replies that p-tag the user would be dropped by the acceptableEvent " +
|
||||
"kind gate before the p-tag check and never notify.",
|
||||
ChatEvent.KIND in NotificationFeedFilter.NOTIFICATION_KINDS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+36
-16
@@ -389,14 +389,24 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
|
||||
| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
|
||||
| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. |
|
||||
| `amy follow USER` / `amy unfollow USER` | Add/remove USER from your kind:3 contact list (fetches the freshest list first). |
|
||||
| `amy graperank [OBSERVER] [--offline] [--publish] [--min-rank N] [--publish-relay URL]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. With `--publish`, reconciles NIP-85 kind:30382 cards signed by a per-observer **service key**: publishes changed/new ranks (cutoff `--min-rank`, default 2), skips unchanged, and **retracts** (kind:5) any card whose target left the graph or fell below the cutoff. |
|
||||
| `amy graperank operator [status \| relay <url>… \| providers]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where cards + retractions publish; `status` shows the master pubkey and relays; `providers` lists the observer → service-pubkey map. |
|
||||
| `amy graperank [OBSERVER] [--offline] [--min-rank N]` | Crawl + score: compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph, then persist the result. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the ranks (cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key** — changed ranks re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `--offline` skips the crawl. |
|
||||
| `amy graperank crawl [OBSERVER] [--max-hops N] [--no-preconnect]` | Pipeline stage 1 — network only: crawl the follow/mute/report graph (kind 3/10000/1984/10002) into the local store, no scoring. Idempotent and cumulative: run it a few times to load everything, then `score`. |
|
||||
| `amy graperank score [OBSERVER]` | Pipeline stage 2 — local only: score from the store and persist the cards (identical to bare `--offline`; same scoring flags). No network, so re-run with different `--rigor`/`--attenuation`/`--min-rank` without re-crawling. |
|
||||
| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Pipeline stage 3 — transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. |
|
||||
| `amy graperank rank USER [--provider PUBKEY] [--refresh]` | The consumer side: read the kind:30382 cards about USER — one rank per provider, newest card each. Local store first; `--refresh` (or a miss) drains the operator relays, the relays your kind:10040 declares, and the bootstrap set. |
|
||||
| `amy graperank refresh [--down] [--up]` | Refresh every locally-known author's WoT record kinds (0/3/10002/1984) from their own outbox: one NIP-77 negentropy reconcile per write relay scoped to its authors, so the next `score` runs on current data without a full re-crawl. (`update` is the pre-rename alias.) |
|
||||
| `amy graperank status` | Read-only local inventory, no network, no signing: WoT record counts in the store (the "do I need to crawl again?" answer), reachability-cache size + age, operator/service-key state, and the persisted card + retraction counts per observer. |
|
||||
| `amy graperank operator [status \| relay <url>… \| keys]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `keys` lists the observer → service-pubkey map (`providers` is the pre-rename alias). |
|
||||
| `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). |
|
||||
| `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]` | The inverse of `register`: remove matching entries (public + private) from your kind:10040 and re-publish it. `--service`/`--relay` narrow the match; without them every entry for that provider key is dropped. |
|
||||
| `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). |
|
||||
| `amy fof get USER` | Follows-of-follows social proof: how many accounts you follow also follow USER. Single-hop, cheap — **not** the computed web of trust (that's `graperank`). Read from the local store; run `fof sync` first to freshen it. |
|
||||
| `amy fof list [--threshold N] [--limit N]` | Rank accounts by that social-proof score — who's most-followed inside your network (discovery). Defaults: `--threshold 1`, `--limit 50`. |
|
||||
| `amy fof sync [--timeout SECS]` | Pull your follows' latest kind:3 from the index relays so the next `get`/`list` is current. (`amy wot …` remains as a deprecation alias for all three.) |
|
||||
|
||||
#### Publishing GrapeRank scores (NIP-85)
|
||||
#### GrapeRank scores are persisted locally, then published (NIP-85)
|
||||
|
||||
Ranks are published as kind:30382 cards, but **not** under your account key. A
|
||||
Ranks are signed as kind:30382 cards, but **not** under your account key. A
|
||||
machine holds one **operator master** seed (`~/.amy/operator/`, stored via the
|
||||
same `--secret-backend` as accounts, independent of any account). From it a
|
||||
distinct, deterministic **service key** is derived per observer:
|
||||
@@ -406,23 +416,32 @@ serviceKey(observer) = sha256(masterPriv ‖ "graperank-provider:" ‖ observerH
|
||||
```
|
||||
|
||||
Because kind:30382 is addressable (`pubkey + d-tag`), the stable per-observer key
|
||||
means re-publishing **replaces** a target's card instead of orphaning it — and
|
||||
losing everything but the master seed still re-derives every key. Set up once and
|
||||
publish:
|
||||
means re-signing **replaces** a target's card instead of orphaning it — and
|
||||
losing everything but the master seed still re-derives every key.
|
||||
|
||||
**Every score run persists its cards.** After scoring, Amy reconciles the result
|
||||
into the local store: new or changed ranks (≥ `--min-rank`, default 2) are
|
||||
signed; unchanged ranks are skipped (no new event id); and any card whose target
|
||||
dropped out of the graph or fell below the cutoff is **retracted** with a kind:5
|
||||
(the store applies it; the tombstone is kept). The local store is the source of
|
||||
truth — `graperank rank USER` reads it offline, and `graperank publish` mirrors
|
||||
it out:
|
||||
|
||||
```bash
|
||||
amy graperank operator relay wss://relay.example.com # where all cards live
|
||||
amy graperank <observer> --publish # sign with the observer's service key
|
||||
amy graperank <observer> # crawl + score + persist cards locally
|
||||
amy graperank publish <observer> # make the operator relay match the local set
|
||||
```
|
||||
|
||||
Each publish **reconciles** against what the service key already published: new or
|
||||
changed ranks (≥ `--min-rank`, default 2) are signed and sent; unchanged ranks are
|
||||
skipped (no new event id); and any card whose target dropped out of the graph or
|
||||
fell below the cutoff is **retracted** with a kind:5. When the observer is your
|
||||
own account (we hold the key), Amy also writes their kind:10040 pointing
|
||||
`30382:rank → serviceKey @ operator relay` to their outbox, so clients can find
|
||||
the cards. For a third-party observer, `graperank operator providers` prints the
|
||||
`observer → service-pubkey` mapping to wire their kind:10040 out-of-band.
|
||||
`publish` never re-scores or re-signs: it runs one NIP-77 up-only reconcile per
|
||||
relay over the service key's kind:30382 + kind:5, so the relay converges to the
|
||||
local card set (deletions included, lost cards restored); a relay that can't
|
||||
negentropy-reconcile gets the full set published event-by-event instead. When
|
||||
the observer is your own account (we hold the key), `publish` also writes their
|
||||
kind:10040 pointing `30382:rank → serviceKey @ operator relay` to their outbox,
|
||||
so clients can find the cards. For a third-party observer, `graperank operator
|
||||
keys` prints the `observer → service-pubkey` mapping to wire their
|
||||
kind:10040 out-of-band.
|
||||
|
||||
### Direct messages (NIP-17)
|
||||
|
||||
@@ -542,6 +561,7 @@ the last facet removes R entirely.
|
||||
| `amy relay add URL` / `remove URL` | Fan-out to the transport lists (nip65 `both` + `dm` + `key-package`). |
|
||||
| `amy relay list` | Print every configured relay bucket. |
|
||||
| `amy relay publish-lists` | Broadcast every configured relay list to the union of your relays. |
|
||||
| `amy relay probe [--timeout SECS] [--concurrency N]` | The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured `rtt-open` into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (`graperank crawl`/`refresh`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) |
|
||||
|
||||
### Local store maintenance
|
||||
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
|
||||
| NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. |
|
||||
| NIP-65 outbox model queries | 🆕 | |
|
||||
| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); publishes kind:30382 `ContactCardEvent` (diffed against prior ranks), plus `register` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. |
|
||||
| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `unregister` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. |
|
||||
| NIP-72 communities | 🆕 | |
|
||||
| NIP-78 app-specific data (settings sync) | 🆕 | |
|
||||
| Long-form (NIP-23) publish / read | 🆕 | |
|
||||
|
||||
@@ -516,11 +516,22 @@ class Context(
|
||||
* accept the exact same identifier formats. Throws on unrecognised input —
|
||||
* command handlers catch [IllegalArgumentException] at the top level and
|
||||
* translate to `{"error": "bad_args"}`.
|
||||
*
|
||||
* A pubkey is always 32 bytes, so we require exactly 64 hex chars: the shared
|
||||
* resolver's fallback runs a lenient `Hex.decode` that turns a short
|
||||
* bech32/hex-ish word (e.g. a mistyped verb like `sync`) into a bogus few-byte
|
||||
* "pubkey" instead of failing — this rejects that so a bad OBSERVER/USER errors
|
||||
* cleanly rather than silently scoring/fetching garbage.
|
||||
*/
|
||||
suspend fun requireUserHex(input: String): com.vitorpamplona.quartz.nip01Core.core.HexKey =
|
||||
com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||
.resolveUserHexOrNull(input, nip05Client)
|
||||
?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)")
|
||||
suspend fun requireUserHex(input: String): com.vitorpamplona.quartz.nip01Core.core.HexKey {
|
||||
val notResolved = "Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)"
|
||||
val hex =
|
||||
com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||
.resolveUserHexOrNull(input, nip05Client)
|
||||
?: throw IllegalArgumentException(notResolved)
|
||||
require(hex.length == 64) { notResolved }
|
||||
return hex
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbox / NIP-65 write relays for this account. Read from the
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.cli.commands.EncryptCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.EventCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.FetchCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.FilterCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.FofCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.FollowCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.GiftCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.GitCommands
|
||||
@@ -71,7 +72,6 @@ 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
|
||||
@@ -293,7 +293,14 @@ 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)
|
||||
"fof" -> FofCommand.dispatch(dataDir, tail)
|
||||
// `wot` overclaimed the whole web-of-trust concept for a cheap
|
||||
// single-hop follower count; renamed to `fof` (follows-of-follows).
|
||||
// Kept as a warning alias — the real WoT engine is `graperank`.
|
||||
"wot" -> {
|
||||
System.err.println("[amy] `wot` is deprecated — use `fof` (follows-of-follows). The computed web of trust is `graperank`.")
|
||||
FofCommand.dispatch(dataDir, tail)
|
||||
}
|
||||
"concord" -> ConcordCommands.dispatch(dataDir, tail)
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
@@ -486,6 +493,12 @@ private fun printUsage() {
|
||||
| relay list print every configured relay bucket
|
||||
| relay publish-lists broadcast every configured relay list
|
||||
| relay info URL fetch + print a relay's NIP-11 info document
|
||||
| relay probe [--timeout SECS] relay census: mass-connect every relay the store
|
||||
| [--concurrency N] knows and record live/dead + measured rtt-open
|
||||
| into the reachability cache (NIP-66 kind:30166),
|
||||
| so reachability-aware commands (graperank crawl/
|
||||
| refresh) skip dead relays and wait once
|
||||
| (--timeout: per wave, default 15s)
|
||||
| outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model)
|
||||
| [--timeout SECS] (USER: npub|nprofile|hex|name@domain)
|
||||
|
|
||||
@@ -608,48 +621,46 @@ private fun printUsage() {
|
||||
| (USER: npub|nprofile|hex|name@domain)
|
||||
|
|
||||
|Web of Trust (GrapeRank):
|
||||
| graperank [OBSERVER] compute subjective trust scores (0..1) for every
|
||||
| [--limit N] [--min-score X] user reachable in the follow/mute/report graph.
|
||||
| [--rigor X] [--attenuation X] Exhaustively crawls each user's kind:10002 outbox
|
||||
| [--max-rounds N] [--max-hops N] for their latest kind:3/10000/1984 until every
|
||||
| [--offline] [--timeout SECS] discovered user has been checked (no user cap;
|
||||
| [--diagnose] --max-hops bounds follow distance, e.g. 8;
|
||||
| --diagnose dumps per-relay telemetry: outcome
|
||||
| mix, yield, latency, and a LIVE/DEAD + limits
|
||||
| classification table of every relay contacted).
|
||||
| [--publish] [--min-rank N] OBSERVER: npub|nprofile|hex|name@domain (default:
|
||||
| [--publish-limit N] [--publish-relay URL] active account). --offline scores from the local
|
||||
| store only. --publish reconciles NIP-85 kind:30382
|
||||
| cards signed by a per-observer service key: sends
|
||||
| new/changed ranks >= --min-rank (default 2), skips
|
||||
| unchanged, and retracts (kind:5) any card whose
|
||||
| target left the graph or fell below the cutoff.
|
||||
| graperank crawl [OBSERVER] network only: crawl the WoT graph (kind 3/10000/
|
||||
| [--max-hops N] [--preconnect-cap N] 1984/10002) into the local store without scoring.
|
||||
| [--no-preconnect] Pre-connects every known-live relay in one parallel
|
||||
| storm (seeded from the reachability cache).
|
||||
| graperank probe [--timeout SECS] relay census: mass-connect every relay the store
|
||||
| [--concurrency N] knows and record live/dead + measured rtt-open into
|
||||
| the reachability cache (NIP-66 kind:30166), so the
|
||||
| next crawl skips dead relays and waits once.
|
||||
| graperank update [--down] [--up] refresh every locally-known author's WoT record kinds
|
||||
| [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all
|
||||
| [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write
|
||||
| [--min-authors N] [--report-limit N] relay, and runs one NIP-77 negentropy reconcile per
|
||||
| relay scoped to its authors. Bidirectional by default;
|
||||
| the deletion settle downloads the relay's kind:5 when
|
||||
| an uploaded record was rejected (author retracted it).
|
||||
| Falls back to a full paged download when a relay
|
||||
| can't reconcile via negentropy.
|
||||
| graperank operator [status|relay <url>… manage the machine's operator keys (~/.amy/operator/,
|
||||
| |providers] independent of accounts): relay sets where cards +
|
||||
| retractions publish; status shows master + relays;
|
||||
| providers lists observer -> service-pubkey.
|
||||
| graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 so
|
||||
| [--service KIND:TAG] [--relay URL] clients can discover it (default: self as the
|
||||
| [--private] 30382:rank provider at your first outbox relay).
|
||||
| graperank providers [USER] [--refresh] list a user's declared NIP-85 trusted providers
|
||||
| [--timeout SECS] (default: active account).
|
||||
| graperank [OBSERVER] crawl + score: subjective trust (0..1) over the
|
||||
| [--min-rank N] [--offline] follow/mute/report graph, then persist the result
|
||||
| [--limit N] [--min-score X] as local NIP-85 kind:30382 cards (ranks >=
|
||||
| [--rigor X] [--attenuation X] --min-rank, default 2). --offline skips the crawl.
|
||||
| [--max-hops N] [--diagnose] OBSERVER: npub|nprofile|hex|name@domain (self).
|
||||
| graperank crawl [OBSERVER] network only: crawl the graph (kind 3/10000/1984/
|
||||
| [--max-hops N] [--max-rounds N] 10002) into the local store, no scoring.
|
||||
| [--no-preconnect] [--preconnect-cap N] Idempotent — run a few times to load everything.
|
||||
| graperank score [OBSERVER] local only: score from the store + persist cards
|
||||
| (= bare --offline; same flags). No network.
|
||||
| graperank publish [OBSERVER] push local cards to the operator relay(s) via a
|
||||
| [--relay URL[,URL…]] [--timeout SECS] NIP-77 up-sync (nothing re-scored), and refresh
|
||||
| [--relay-concurrency N] the observer's kind:10040 when we hold their key.
|
||||
| graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER, one rank per
|
||||
| [--refresh] [--timeout SECS] provider; --refresh drains relays on a miss.
|
||||
| graperank status read-only local inventory: record counts, cache
|
||||
| freshness, operator state, cards per observer.
|
||||
| graperank refresh [--down] [--up] re-sync known authors' records (kind 0/3/10002/
|
||||
| [--relay-concurrency N] [--author-chunk N] 1984) from their outboxes via NIP-77, so score
|
||||
| [--min-authors N] [--report-limit N] runs on current data. (`update` is the alias.)
|
||||
| [--no-sync-deletions] [--timeout SECS]
|
||||
| graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040
|
||||
| [--service KIND:TAG] [--relay URL] (default: self as 30382:rank at your 1st outbox).
|
||||
| [--private]
|
||||
| graperank unregister PROVIDER remove matching entries from your kind:10040;
|
||||
| [--service KIND:TAG] [--relay URL] --service/--relay narrow, else all for that key.
|
||||
| graperank providers [USER] [--refresh] list a user's declared NIP-85 providers.
|
||||
| [--timeout SECS]
|
||||
| graperank operator operator keys (~/.amy/operator/): `relay URL…`
|
||||
| [status | relay URL… | keys] sets the publish target; `keys` maps observer
|
||||
| -> service-key. (default: status)
|
||||
| graperank probe alias for `relay probe` (the relay census).
|
||||
|
|
||||
|Follows-of-follows (social proof — the cheap counterpart to graperank):
|
||||
| fof get USER USER's score: how many accounts you follow also
|
||||
| follow them (single-hop social proof, not trust).
|
||||
| fof list [--threshold N] [--limit N] accounts ranked by that score — who's most
|
||||
| followed inside your network (default N: 1 / 50).
|
||||
| fof sync [--timeout SECS] refresh your follows' kind:3 from the index relays
|
||||
| so the next get/list is current (`wot` = alias).
|
||||
|
|
||||
|Zaps (NIP-57):
|
||||
| zap user USER SATS build a profile zap-request, fetch a BOLT11
|
||||
|
||||
+15
-9
@@ -38,15 +38,21 @@ import kotlinx.coroutines.cancel
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* `amy wot <get|list|sync>` — Web-of-Trust score queries.
|
||||
* `amy fof <get|list|sync>` — follows-of-follows social-proof scores.
|
||||
*
|
||||
* 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
|
||||
* kind:3 follow set who also follow X — cheap single-hop social proof, NOT
|
||||
* the computed web of trust (that's `amy graperank`). `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.
|
||||
*
|
||||
* - `fof get USER` — X's score (how many of your follows follow X).
|
||||
* - `fof list` — accounts ranked by score (discovery: who's most
|
||||
* followed inside your network).
|
||||
* - `fof sync` — refresh your follows' kind:3 from relays.
|
||||
*/
|
||||
object WotCommand {
|
||||
object FofCommand {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
@@ -61,13 +67,13 @@ object WotCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private fun usage(): Int = Output.error("bad_args", "wot <get|list|sync>")
|
||||
private fun usage(): Int = Output.error("bad_args", "fof <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>")
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "fof get <pubkey|npub>")
|
||||
val userArg = rest[0]
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
@@ -148,8 +154,8 @@ object WotCommand {
|
||||
// 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
|
||||
// matches the `fof sync` behaviour of always
|
||||
// re-asking. A future optimisation
|
||||
// could pre-populate a `Map<HexKey,
|
||||
// AdvertisedRelayListEvent>` before dispatch.
|
||||
null
|
||||
@@ -36,30 +36,28 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
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.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachabilityStore
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
@@ -80,29 +78,40 @@ import kotlin.math.roundToInt
|
||||
* unreachable outbox is retried a few times), then runs the scoring engine in
|
||||
* `commons/wot`.
|
||||
*
|
||||
* Prints a ranked list (text, or one JSON object under `--json`). With
|
||||
* `--publish`, results are also published as NIP-85 kind:30382 `ContactCardEvent`
|
||||
* trusted assertions (one per scored user, `rank = round(score*100)`).
|
||||
*
|
||||
* The crawl and the computation are separable, because the crawl persists every
|
||||
* event it fetches to the store and the score is a pure function over it:
|
||||
* The pipeline is three separable stages, each with its own verb, and the local
|
||||
* store is the source of truth between them (the crawl persists every event it
|
||||
* fetches; the score is a pure function over the store; every score run persists
|
||||
* its result as locally-signed NIP-85 kind:30382 cards):
|
||||
* - `amy graperank crawl [OBSERVER]` — network only: crawl the reachable graph's
|
||||
* kind 3/10000/1984/10002 into the local store (aliased as the former `sync`).
|
||||
* Idempotent and cumulative, so run it a few times to make sure everything is
|
||||
* loaded. Scores nothing.
|
||||
* - `amy graperank score [OBSERVER]` — local only: build the graph from the store
|
||||
* and score (same as bare `--offline`). Instant and param-tunable; repeat with
|
||||
* different `--rigor`/`--attenuation`/cutoffs without re-crawling.
|
||||
* - `amy graperank probe` — the relay census: mass-connect every relay the store
|
||||
* kind 3/10000/1984/10002 into the local store. Idempotent and cumulative, so
|
||||
* run it a few times to make sure everything is loaded. Scores nothing.
|
||||
* - `amy graperank status` — read-only inventory of all of the above: WoT record
|
||||
* counts, reachability-cache freshness, operator state, persisted card sets.
|
||||
* Answers "do I need to crawl again?" with no network and no signing.
|
||||
* - `amy graperank score [OBSERVER]` — local only: build the graph from the store,
|
||||
* score (same as bare `--offline`), and ALWAYS reconcile the result into the
|
||||
* store as kind:30382 [ContactCardEvent] cards signed by the observer's
|
||||
* per-observer service key (`rank = round(score*100)`, cutoff `--min-rank`):
|
||||
* changed ranks are re-signed, unchanged ones skipped, dropped targets
|
||||
* retracted with a kind:5. That persisted card set is what `publish` and
|
||||
* `rank` reuse — scores are never ephemeral.
|
||||
* - `amy graperank publish [OBSERVER]` — transport only: make the operator
|
||||
* relay(s) converge to the local card set via a NIP-77 up-only reconcile
|
||||
* (nothing is re-signed or re-scored), and refresh the observer's kind:10040
|
||||
* pointer when we hold their key.
|
||||
* - `amy relay probe` — the relay census: mass-connect every relay the store
|
||||
* knows and record live/dead + measured RTT into the reachability cache, so the
|
||||
* next crawl skips the dead and pre-connects the living in one parallel storm.
|
||||
* Lives in [RelayCommands] (`graperank probe` is kept as an alias).
|
||||
* - bare `amy graperank [OBSERVER]` — the convenience combo: crawl then score.
|
||||
*
|
||||
* Sub-verbs complete the NIP-85 provider experience — the discovery layer that
|
||||
* lets clients find and consume those assertions:
|
||||
* Sub-verbs complete the NIP-85 experience — discovery and consumption:
|
||||
* - `amy graperank rank USER` — read the kind:30382 cards about USER (local
|
||||
* store first, `--refresh` to drain providers' relays): the consumer side.
|
||||
* - `amy graperank register` — advertise a `30382:rank` provider in the
|
||||
* account's kind:10040 [TrustProviderListEvent] (defaults to self, so a
|
||||
* provider publishing ranks announces where to find them).
|
||||
* provider publishing ranks announces where to find them);
|
||||
* `amy graperank unregister PROVIDER` removes entries again.
|
||||
* - `amy graperank providers [USER]` — list a user's trusted providers.
|
||||
*/
|
||||
object GrapeRankCommand {
|
||||
@@ -196,14 +205,21 @@ object GrapeRankCommand {
|
||||
// NIP-05, or nothing) is the OBSERVER positional for a score computation.
|
||||
when (tail.firstOrNull()) {
|
||||
"register" -> register(dataDir, tail.drop(1).toTypedArray())
|
||||
"unregister" -> unregister(dataDir, tail.drop(1).toTypedArray())
|
||||
"providers" -> providers(dataDir, tail.drop(1).toTypedArray())
|
||||
"operator" -> operator(dataDir, tail.drop(1).toTypedArray())
|
||||
// `sync` is the pre-rename name kept as a back-compat alias; `crawl` is
|
||||
// canonical (disambiguates from negentropy `amy sync` / `graperank update`).
|
||||
"crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray())
|
||||
"probe" -> probe(dataDir, tail.drop(1).toTypedArray())
|
||||
"update" -> update(dataDir, tail.drop(1).toTypedArray())
|
||||
"crawl" -> crawl(dataDir, tail.drop(1).toTypedArray())
|
||||
"status" -> status(dataDir)
|
||||
// The relay census outgrew graperank (it feeds the shared NIP-66
|
||||
// reachability cache every command reads) and moved to `amy relay
|
||||
// probe`; this alias keeps the old spelling working.
|
||||
"probe" -> RelayCommands.probe(dataDir, tail.drop(1).toTypedArray())
|
||||
// `refresh` is canonical (it refreshes the WoT record kinds from each
|
||||
// author's outbox); `update` is the pre-rename back-compat alias.
|
||||
"refresh", "update" -> refresh(dataDir, tail.drop(1).toTypedArray())
|
||||
"score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true)
|
||||
"publish" -> publish(dataDir, tail.drop(1).toTypedArray())
|
||||
"rank" -> rank(dataDir, tail.drop(1).toTypedArray())
|
||||
else -> run(dataDir, tail)
|
||||
}
|
||||
|
||||
@@ -227,17 +243,11 @@ object GrapeRankCommand {
|
||||
// the result JSON, so keep local copies for that.
|
||||
val parkTimeoutMs = args.longFlag("park-timeout", 40L) * 1000
|
||||
val insertBatch = args.intFlag("insert-batch", 500)
|
||||
val doPublish = args.bool("publish")
|
||||
// Publish cutoff: only cards with rank >= this are published; existing
|
||||
// cards for targets below it (or gone from the graph) are retracted. Rank
|
||||
// is round(score*100), so 2 drops the ~0.015-and-below barely-trusted tail.
|
||||
// Card cutoff: only scores with rank >= this get a local kind:30382 card;
|
||||
// existing cards for targets below it (or gone from the graph) are
|
||||
// retracted. Rank is round(score*100), so 2 drops the ~0.015-and-below
|
||||
// barely-trusted tail.
|
||||
val minRank = args.intFlag("min-rank", 2)
|
||||
val publishLimit = args.intFlag("publish-limit", 500)
|
||||
val publishRelaysArg = args.flag("publish-relay")
|
||||
// Benchmark: build + sign one kind:30382 card per scored user (rank >=
|
||||
// --min-rank) with a throwaway key and time it, WITHOUT publishing.
|
||||
// Measures the id-hash + Schnorr-sign cost of emitting the full card set.
|
||||
val benchSign = args.bool("bench-sign")
|
||||
|
||||
val params =
|
||||
GrapeRankParams(
|
||||
@@ -348,84 +358,43 @@ object GrapeRankCommand {
|
||||
},
|
||||
)
|
||||
|
||||
if (doPublish) {
|
||||
// The cards for THIS observer are signed by a dedicated, stable
|
||||
// per-observer service key derived from the machine's operator
|
||||
// master (see OperatorKeys) — not the account key. Same key across
|
||||
// runs means re-signing a card replaces the addressable prior one.
|
||||
val opKeys = ctx.dataDir.operatorKeys()
|
||||
val serviceKey = opKeys.serviceKey(observer)
|
||||
val serviceSigner = NostrSignerInternal(serviceKey)
|
||||
val providerPubkey = serviceKey.pubKey.toHexKey()
|
||||
result["provider_pubkey"] = providerPubkey
|
||||
// Every score run persists its result: the desired card set (every user
|
||||
// at or above the rank cutoff) is reconciled into the LOCAL store as
|
||||
// kind:30382 cards — signed by a dedicated, stable per-observer service
|
||||
// key derived from the machine's operator master (see OperatorKeys),
|
||||
// not the account key. Changed ranks are re-signed (the addressable
|
||||
// card is replaced), unchanged ones skipped, dropped targets retracted
|
||||
// with a kind:5. `graperank publish` and `graperank rank` reuse this
|
||||
// set; no relay is touched here.
|
||||
val opKeys = ctx.dataDir.operatorKeys()
|
||||
val serviceKey = opKeys.serviceKey(observer)
|
||||
val providerPubkey = serviceKey.pubKey.toHexKey()
|
||||
val desiredCards =
|
||||
rankedIds
|
||||
.filter { rankOf(scores[it]) >= minRank }
|
||||
.map { graph.pubkeyOf(it) to rankOf(scores[it]) }
|
||||
|
||||
// Cards go to the operator's own relay(s), where the whole
|
||||
// trusted-assertion set lives; --publish-relay overrides.
|
||||
val relays =
|
||||
publishRelaysArg
|
||||
?.split(",")
|
||||
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
|
||||
?.toSet()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: opKeys.operatorRelays()
|
||||
val cardsStart = System.nanoTime()
|
||||
val local =
|
||||
GrapeRankPublisher(ctx.store) { System.err.println(it) }
|
||||
.reconcileLocal(
|
||||
providerSigner = NostrSignerInternal(serviceKey),
|
||||
providerPubkey = providerPubkey,
|
||||
scored = desiredCards,
|
||||
)
|
||||
val cardsMs = (System.nanoTime() - cardsStart) / 1_000_000
|
||||
System.err.println(
|
||||
"[graperank] local cards: ${local.signed} signed, ${local.unchanged} unchanged, " +
|
||||
"${local.retracted} retracted in $cardsMs ms — `amy graperank publish` pushes them to the operator relay",
|
||||
)
|
||||
|
||||
if (relays.isEmpty()) {
|
||||
result["published"] = 0
|
||||
result["publish_error"] = "no operator relay configured — run `amy graperank operator relay <url>` or pass --publish-relay"
|
||||
} else {
|
||||
// The scorer's desired card set: every user at or above the rank
|
||||
// cutoff, as (target, rank). GrapeRankPublisher reconciles this
|
||||
// against what this provider key already published and upserts /
|
||||
// retracts the difference.
|
||||
val publishable =
|
||||
rankedIds
|
||||
.filter { rankOf(scores[it]) >= minRank }
|
||||
.map { graph.pubkeyOf(it) to rankOf(scores[it]) }
|
||||
|
||||
val publisher = GrapeRankPublisher(ctx.store) { event, to -> ctx.publish(event, to) }
|
||||
val pub =
|
||||
publisher.reconcileAndPublish(
|
||||
providerSigner = serviceSigner,
|
||||
providerPubkey = providerPubkey,
|
||||
scored = publishable,
|
||||
relays = relays,
|
||||
publishLimit = publishLimit,
|
||||
)
|
||||
|
||||
result["skipped_unchanged"] = pub.skippedUnchanged
|
||||
if (pub.truncated > 0) result["publish_truncated"] = pub.truncated
|
||||
result["published"] = pub.published
|
||||
result["publish_rejected"] = pub.publishRejected
|
||||
result["deleted"] = pub.deleted
|
||||
result["delete_rejected"] = pub.deleteRejected
|
||||
result["published_kind"] = ContactCardEvent.KIND
|
||||
result["published_to"] = relays.map { it.url }
|
||||
|
||||
// Help the observer point clients at this provider: publish their
|
||||
// kind:10040 (30382:rank -> providerPubkey @ operator relay) to
|
||||
// their outbox — but only when we actually hold their key.
|
||||
maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first())?.let {
|
||||
result["observer_10040"] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (benchSign) {
|
||||
// Throwaway key — these cards are for timing only and never leave
|
||||
// the process, so no real identity signs them.
|
||||
val tempSigner = NostrSignerInternal(KeyPair())
|
||||
val cards =
|
||||
rankedIds
|
||||
.filter { rankOf(scores[it]) >= minRank }
|
||||
.map { graph.pubkeyOf(it) to rankOf(scores[it]) }
|
||||
val signStart = System.nanoTime()
|
||||
val signed = signCards(cards, tempSigner)
|
||||
val signMs = (System.nanoTime() - signStart) / 1_000_000
|
||||
val perSec = if (signMs > 0) signed * 1000L / signMs else 0
|
||||
System.err.println("[graperank] signed $signed kind:30382 cards in $signMs ms ($perSec/s, temp key, not published)")
|
||||
result["bench_signed"] = signed
|
||||
result["bench_sign_ms"] = signMs
|
||||
}
|
||||
result["provider_pubkey"] = providerPubkey
|
||||
result["min_rank"] = minRank
|
||||
result["cards_total"] = desiredCards.size
|
||||
result["cards_signed"] = local.signed
|
||||
result["cards_unchanged"] = local.unchanged
|
||||
result["cards_retracted"] = local.retracted
|
||||
result["cards_ms"] = cardsMs
|
||||
|
||||
Output.emit(result)
|
||||
return 0
|
||||
@@ -543,9 +512,9 @@ object GrapeRankCommand {
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank crawl [OBSERVER]` — network-only WoT data crawl (aliased as the
|
||||
* former `sync`). Crawls the reachable follow/mute/report graph into the local
|
||||
* store (kind 3/10000/1984/10002) and reports what it loaded, WITHOUT scoring.
|
||||
* `amy graperank crawl [OBSERVER]` — network-only WoT data crawl. Crawls the
|
||||
* reachable follow/mute/report graph into the local store (kind
|
||||
* 3/10000/1984/10002) and reports what it loaded, WITHOUT scoring.
|
||||
* Idempotent + cumulative: run it a few times to make sure everything is loaded,
|
||||
* then `graperank score`.
|
||||
*/
|
||||
@@ -586,79 +555,73 @@ object GrapeRankCommand {
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank probe [--timeout SECS] [--concurrency N]` —
|
||||
* the relay census. Mass-connects the ENTIRE relay universe the local store knows
|
||||
* (every relay advertised in any stored kind:10002, deduped per host, plus
|
||||
* everything already in the reachability cache) in parallel waves with a no-op
|
||||
* REQ, so the "is this relay alive, and how slow?" wait is paid once, up front,
|
||||
* concurrently — then records per-relay verdicts with real measured `rtt-open`
|
||||
* into the NIP-66 reachability cache (kind:30166).
|
||||
*
|
||||
* The next `graperank crawl` reads that cache to (a) skip the dead set without
|
||||
* dialing it and (b) pre-connect the live set in one storm — separating "working
|
||||
* but slow" (kept; the crawler's patient park path waits for them) from "not
|
||||
* working" (skipped entirely). Typical flow the first time:
|
||||
* `graperank crawl --max-hops 2` (cheap, saves the relay lists) → `graperank
|
||||
* probe` → full `graperank crawl`.
|
||||
* `amy graperank status` — read-only inventory of everything a GrapeRank run
|
||||
* depends on, straight from the local store: WoT record counts (the "do I
|
||||
* need to crawl again?" answer), reachability-cache size + freshness,
|
||||
* operator/service-key state, and the persisted card set per observer.
|
||||
* No network, no signing, no side effects.
|
||||
*/
|
||||
private suspend fun probe(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val timeoutMs = args.longFlag("timeout", 15L) * 1000
|
||||
val waveSize = args.intFlag("concurrency", Context.defaultPreconnectCap)
|
||||
|
||||
private suspend fun status(dataDir: DataDir): Int {
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val cached = ctx.reachability.snapshot()
|
||||
val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead
|
||||
if (universe.isEmpty()) {
|
||||
Output.emit(
|
||||
// Deliberately no ctx.prepare(): status must stay offline (nothing here
|
||||
// needs a relay connection or marmot state).
|
||||
suspend fun countKind(kind: Int) = ctx.store.count(Filter(kinds = listOf(kind)))
|
||||
|
||||
// The store keeps only the newest replaceable event per author, so the
|
||||
// kind:3 count is "users whose follow list we hold" — the graph size a
|
||||
// `score` would see.
|
||||
val contactLists = countKind(ContactListEvent.KIND)
|
||||
|
||||
// Read-only reachability view: ctx.reachability would lazily derive the
|
||||
// monitor key and thereby CREATE the operator master on a fresh machine;
|
||||
// a throwaway signer reads the same kind:30166 records without that
|
||||
// side effect (the signer is only used for writes).
|
||||
val reach = RelayReachabilityStore(store = ctx.store, signer = NostrSignerInternal(KeyPair())).snapshot()
|
||||
val newestReachRecord =
|
||||
ctx.store
|
||||
.query<Event>(Filter(kinds = listOf(RelayDiscoveryEvent.KIND), limit = 1))
|
||||
.firstOrNull()
|
||||
?.createdAt
|
||||
|
||||
val opKeys = ctx.dataDir.operatorKeys()
|
||||
val cards =
|
||||
opKeys.providers().map { (observer, rec) ->
|
||||
linkedMapOf<String, Any?>(
|
||||
"probed" to 0,
|
||||
"note" to "no relays known locally — run `amy graperank crawl` first to gather kind:10002 relay lists",
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
System.err.println(
|
||||
"[relay-probe] probing ${universe.size} relays in waves of $waveSize " +
|
||||
"(${timeoutMs / 1000}s per wave; open-files limit ${Context.maxFileDescriptors})",
|
||||
)
|
||||
val result =
|
||||
RelayProber(ctx.client) { System.err.println(it) }
|
||||
.probe(universe, timeoutMs, waveSize)
|
||||
|
||||
ctx.reachability.recordProbed(result.reachableRttMs(), result.deadRelays())
|
||||
|
||||
val rtts =
|
||||
result.reachable
|
||||
.map { it.rttOpenMs }
|
||||
.filter { it >= 0 }
|
||||
.sorted()
|
||||
|
||||
fun pct(p: Int): Long? = if (rtts.isEmpty()) null else rtts[(rtts.size - 1) * p / 100]
|
||||
val slowest =
|
||||
result.reachable
|
||||
.filter { it.rttOpenMs >= 0 }
|
||||
.sortedByDescending { it.rttOpenMs }
|
||||
.take(10)
|
||||
.map { mapOf("relay" to it.relay.url, "rtt_open_ms" to it.rttOpenMs) }
|
||||
val authWalled = result.reachable.count { it.error?.startsWith("closed:") == true }
|
||||
"observer" to observer,
|
||||
"provider_pubkey" to rec.providerPubKey,
|
||||
"cards" to ctx.store.count(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(rec.providerPubKey))),
|
||||
"retractions" to ctx.store.count(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(rec.providerPubKey))),
|
||||
)
|
||||
}
|
||||
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"probed" to result.verdicts.size,
|
||||
"reachable" to result.reachable.size,
|
||||
"dead" to result.dead.size,
|
||||
"closed_by_policy" to authWalled,
|
||||
"elapsed_ms" to result.elapsedMs,
|
||||
"rtt_open_p50_ms" to pct(50),
|
||||
"rtt_open_p90_ms" to pct(90),
|
||||
"rtt_open_p99_ms" to pct(99),
|
||||
"slowest" to slowest,
|
||||
"store" to
|
||||
linkedMapOf<String, Any?>(
|
||||
"profiles" to countKind(MetadataEvent.KIND),
|
||||
"contact_lists" to contactLists,
|
||||
"mute_lists" to countKind(MuteListEvent.KIND),
|
||||
"reports" to countKind(ReportEvent.KIND),
|
||||
"relay_lists" to countKind(AdvertisedRelayListEvent.KIND),
|
||||
),
|
||||
"reachability" to
|
||||
linkedMapOf<String, Any?>(
|
||||
"live" to reach.live.size,
|
||||
"dead" to reach.dead.size,
|
||||
"newest_record_age_s" to newestReachRecord?.let { (TimeUtils.now() - it).coerceAtLeast(0) },
|
||||
),
|
||||
"operator" to
|
||||
if (opKeys.exists()) {
|
||||
linkedMapOf<String, Any?>(
|
||||
"initialized" to true,
|
||||
"master_pubkey" to opKeys.masterPubKey(),
|
||||
"relays" to opKeys.operatorRelays().map { it.url },
|
||||
)
|
||||
} else {
|
||||
linkedMapOf<String, Any?>("initialized" to false)
|
||||
},
|
||||
"cards" to cards,
|
||||
"note" to if (contactLists == 0) "no contact lists in the local store — run `amy graperank crawl` first" else null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -666,7 +629,7 @@ object GrapeRankCommand {
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank update [flags]` — refresh every locally-known author's WoT
|
||||
* `amy graperank refresh [flags]` (alias: `update`) — refresh every locally-known author's WoT
|
||||
* record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the
|
||||
* next `graperank score` runs on current data without a full follow-graph crawl.
|
||||
*
|
||||
@@ -685,7 +648,7 @@ object GrapeRankCommand {
|
||||
* `--report-limit N` (per-relay rows in the JSON, default 50),
|
||||
* `--down` / `--up` / `--no-sync-deletions`.
|
||||
*/
|
||||
private suspend fun update(
|
||||
private suspend fun refresh(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
@@ -714,7 +677,7 @@ object GrapeRankCommand {
|
||||
down = downFlag || !upFlag,
|
||||
up = upFlag || !downFlag,
|
||||
syncDeletions = !args.bool("no-sync-deletions"),
|
||||
relayConcurrency = args.intFlag("relay-concurrency", 4),
|
||||
relayConcurrency = args.intFlag("relay-concurrency", args.intFlag("concurrency", 4)),
|
||||
authorChunk = args.intFlag("author-chunk", 500),
|
||||
minAuthors = args.intFlag("min-authors", 1),
|
||||
idleTimeoutMs = args.longFlag("timeout", 30L) * 1000,
|
||||
@@ -780,45 +743,214 @@ object GrapeRankCommand {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + sign one kind:30382 [ContactCardEvent] per (target, rank), fanned
|
||||
* out across CPU cores (id-hash + Schnorr sign is CPU-bound). The signed
|
||||
* events are discarded — this only exists to time card generation. Returns
|
||||
* the number signed.
|
||||
* `amy graperank publish [OBSERVER] [--relay URL[,URL…]] [--relay-concurrency N] [--timeout SECS]`
|
||||
*
|
||||
* Transport only: make the operator relay(s) converge to the local card set
|
||||
* that `graperank score` persisted for OBSERVER (default: the active account).
|
||||
* One NIP-77 up-only reconcile per relay over the provider service key's
|
||||
* kind:30382 cards + kind:5 retractions — nothing is re-scored or re-signed,
|
||||
* and a card the relay lost is restored. A relay that can't reconcile gets the
|
||||
* full local set blast-published instead. Also refreshes the observer's
|
||||
* kind:10040 provider pointer when we hold their key.
|
||||
*/
|
||||
private suspend fun signCards(
|
||||
cards: List<Pair<HexKey, Int>>,
|
||||
signer: NostrSigner,
|
||||
private suspend fun publish(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (cards.isEmpty()) return 0
|
||||
val cores = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
|
||||
val chunkSize = ((cards.size + cores - 1) / cores).coerceAtLeast(1)
|
||||
return coroutineScope {
|
||||
cards
|
||||
.chunked(chunkSize)
|
||||
.map { chunk ->
|
||||
async(Dispatchers.Default) {
|
||||
for ((target, rank) in chunk) {
|
||||
ContactCardEvent.create(
|
||||
targetUser = target,
|
||||
signer = signer,
|
||||
publicInitializer = { add(RankTag.assemble(rank)) },
|
||||
val args = Args(rest)
|
||||
val observerArg = args.positionalOrNull(0)
|
||||
val relayArg = args.flag("relay")
|
||||
// --relay-concurrency is canonical for "relays worked at once" across the
|
||||
// graperank verbs; --concurrency is accepted everywhere as its alias.
|
||||
val relayConcurrency = args.intFlag("relay-concurrency", args.intFlag("concurrency", 4))
|
||||
// Idle watchdog per relay reconcile (not a total budget), like `refresh`.
|
||||
val idleTimeoutMs = args.longFlag("timeout", 30L) * 1000
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val observer = observerArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val opKeys = ctx.dataDir.operatorKeys()
|
||||
val providerPubkey = opKeys.serviceKey(observer).pubKey.toHexKey()
|
||||
|
||||
// Cards live on the operator's own relay(s); --relay overrides.
|
||||
val relays =
|
||||
relayArg
|
||||
?.split(",")
|
||||
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
|
||||
?.toSet()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: opKeys.operatorRelays()
|
||||
if (relays.isEmpty()) {
|
||||
return Output.error("no_relays", "no operator relay configured — run `amy graperank operator relay <url>` or pass --relay")
|
||||
}
|
||||
|
||||
val publisher = GrapeRankPublisher(ctx.store) { System.err.println(it) }
|
||||
val sync =
|
||||
publisher.syncToRelays(
|
||||
client = ctx.client,
|
||||
providerPubkey = providerPubkey,
|
||||
relays = relays,
|
||||
relayConcurrency = relayConcurrency,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
)
|
||||
|
||||
if (sync.cards == 0 && sync.deletions == 0) {
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"observer" to observer,
|
||||
"provider_pubkey" to providerPubkey,
|
||||
"cards" to 0,
|
||||
"note" to "no local cards for this observer — run `amy graperank score` first",
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Help the observer point clients at this provider: publish their
|
||||
// kind:10040 (30382:rank -> providerPubkey @ operator relay) to
|
||||
// their outbox — but only when we actually hold their key.
|
||||
val observer10040 = maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first())
|
||||
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"observer" to observer,
|
||||
"provider_pubkey" to providerPubkey,
|
||||
"cards" to sync.cards,
|
||||
"deletions" to sync.deletions,
|
||||
"relays" to sync.perRelay.size,
|
||||
"relays_ok" to sync.perRelay.count { it.ok },
|
||||
"relays_failed" to sync.perRelay.count { !it.ok },
|
||||
"uploaded" to sync.perRelay.sumOf { it.uploaded },
|
||||
"fallback_published" to sync.perRelay.sumOf { it.fallbackPublished },
|
||||
"per_relay" to
|
||||
sync.perRelay.map {
|
||||
linkedMapOf<String, Any?>(
|
||||
"relay" to it.relay.url,
|
||||
"uploaded" to it.uploaded,
|
||||
"fallback_published" to it.fallbackPublished,
|
||||
"fallback_rejected" to it.fallbackRejected,
|
||||
"error" to it.error,
|
||||
)
|
||||
}
|
||||
chunk.size
|
||||
}
|
||||
}.awaitAll()
|
||||
.sum()
|
||||
},
|
||||
"observer_10040" to observer10040,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank operator [status | relay <url>… | providers]`
|
||||
* `amy graperank rank USER [--provider PUBKEY] [--refresh] [--timeout SECS]`
|
||||
*
|
||||
* The consumer side of NIP-85: read the kind:30382 cards about USER and print
|
||||
* one rank per provider (newest card each). Cache-first — a `graperank score`
|
||||
* run on this machine already left its cards in the store — falling back to a
|
||||
* relay drain on a miss or with `--refresh` (sources: the operator relays, the
|
||||
* relays declared in the account's kind:10040, and the bootstrap set).
|
||||
* `--provider` narrows to one provider key.
|
||||
*/
|
||||
private suspend fun rank(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val userArg =
|
||||
args.positionalOrNull(0)
|
||||
?: return Output.error("bad_args", "usage: amy graperank rank USER [--provider PUBKEY] [--refresh] [--timeout SECS]")
|
||||
val providerArg = args.flag("provider")
|
||||
val refresh = args.bool("refresh")
|
||||
val timeoutMs = args.longFlag("timeout", 8L) * 1000
|
||||
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val user = ctx.requireUserHex(userArg)
|
||||
val provider = providerArg?.let { ctx.requireUserHex(it) }
|
||||
val cardFilter =
|
||||
Filter(
|
||||
kinds = listOf(ContactCardEvent.KIND),
|
||||
tags = mapOf("d" to listOf(user)),
|
||||
authors = provider?.let { listOf(it) },
|
||||
)
|
||||
|
||||
suspend fun localCards(): List<ContactCardEvent> = ctx.store.query<Event>(cardFilter).filterIsInstance<ContactCardEvent>()
|
||||
|
||||
var cards = localCards()
|
||||
if (refresh || cards.isEmpty()) {
|
||||
val relays = rankSourceRelays(ctx, provider)
|
||||
if (relays.isNotEmpty()) {
|
||||
ctx.drain(relays.associateWith { listOf(cardFilter.copy(limit = 50)) }, timeoutMs)
|
||||
cards = localCards()
|
||||
}
|
||||
}
|
||||
|
||||
// Newest card per provider key, strongest assertion first.
|
||||
val newest =
|
||||
cards
|
||||
.groupBy { it.pubKey }
|
||||
.mapNotNull { (_, list) -> list.maxByOrNull { it.createdAt } }
|
||||
.sortedWith(compareByDescending<ContactCardEvent> { it.rank() ?: -1 }.thenByDescending { it.createdAt })
|
||||
|
||||
// A provider key this machine's operator master derived maps back to
|
||||
// the observer whose subjective view the rank expresses.
|
||||
val providerToObserver =
|
||||
ctx.dataDir
|
||||
.operatorKeys()
|
||||
.providers()
|
||||
.entries
|
||||
.associate { (observer, rec) -> rec.providerPubKey to observer }
|
||||
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"user" to user,
|
||||
"found" to newest.isNotEmpty(),
|
||||
"cards" to
|
||||
newest.map { card ->
|
||||
linkedMapOf<String, Any?>(
|
||||
"provider" to card.pubKey,
|
||||
"rank" to card.rank(),
|
||||
"observer" to providerToObserver[card.pubKey],
|
||||
"created_at" to card.createdAt,
|
||||
"event_id" to card.id,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays worth draining for someone's kind:30382 cards: the machine's own
|
||||
* operator relay(s), every relay the account's kind:10040 declares for a
|
||||
* 30382 service (narrowed to [provider] when given), and the bootstrap set.
|
||||
*/
|
||||
private suspend fun rankSourceRelays(
|
||||
ctx: Context,
|
||||
provider: HexKey?,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
val declared =
|
||||
if (!ctx.anonymous) {
|
||||
providerListOf(ctx, ctx.identity.pubKeyHex)
|
||||
?.serviceProviders()
|
||||
?.filter { it.service.kind == ContactCardEvent.KIND && (provider == null || it.pubkey == provider) }
|
||||
?.map { it.relayUrl }
|
||||
.orEmpty()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return ctx.dataDir.operatorKeys().operatorRelays() + declared + ctx.bootstrapRelays() + Constants.eventFinderRelays
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank operator [status | relay <url>… | keys]`
|
||||
*
|
||||
* Manage the machine's operator keys used to sign trusted-assertion cards.
|
||||
* - `status` (default): master pubkey, configured relay(s), provider count.
|
||||
* - `status` (default): master pubkey, configured relay(s), service-key count.
|
||||
* - `relay <url>…`: set the operator relay(s) the cards + retractions publish
|
||||
* to; creates the operator master on first use.
|
||||
* - `providers`: the observer -> provider-pubkey mapping learned so far.
|
||||
* - `keys` (alias: the pre-rename `providers`, which collided with
|
||||
* `graperank providers`): the observer -> service-key mapping derived so
|
||||
* far — what a third-party observer wires into their kind:10040.
|
||||
*/
|
||||
private fun operator(
|
||||
dataDir: DataDir,
|
||||
@@ -835,11 +967,11 @@ object GrapeRankCommand {
|
||||
0
|
||||
}
|
||||
|
||||
"providers" -> {
|
||||
"keys", "providers" -> {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"master_pubkey" to if (opKeys.exists()) opKeys.masterPubKey() else null,
|
||||
"providers" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) },
|
||||
"keys" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) },
|
||||
),
|
||||
)
|
||||
0
|
||||
@@ -854,14 +986,14 @@ object GrapeRankCommand {
|
||||
"initialized" to true,
|
||||
"master_pubkey" to opKeys.masterPubKey(),
|
||||
"relays" to opKeys.operatorRelays().map { it.url },
|
||||
"providers" to opKeys.providers().size,
|
||||
"keys" to opKeys.providers().size,
|
||||
),
|
||||
)
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | providers)")
|
||||
else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | keys)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -948,6 +1080,96 @@ object GrapeRankCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL] [--timeout SECS]`
|
||||
*
|
||||
* The inverse of [register]: drop matching provider entries — public AND
|
||||
* private — from the account's kind:10040 [TrustProviderListEvent] and
|
||||
* re-publish it. PROVIDER is required; `--service` / `--relay` narrow the
|
||||
* match when the same key is listed for several services or relays — without
|
||||
* them, every entry for that provider key is removed. Fetches the freshest
|
||||
* list first so the removal applies to the current provider set.
|
||||
*/
|
||||
private suspend fun unregister(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val providerArg =
|
||||
args.positionalOrNull(0)
|
||||
?: args.flag("provider")
|
||||
?: return Output.error("bad_args", "usage: amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]")
|
||||
val serviceArg = args.flag("service")
|
||||
val relayArg = args.flag("relay")
|
||||
val timeoutMs = args.longFlag("timeout", 8L) * 1000
|
||||
|
||||
val service =
|
||||
serviceArg?.let {
|
||||
ServiceType.parse(it) ?: return Output.error("bad_args", "--service must be KIND:TAG, e.g. 30382:rank")
|
||||
}
|
||||
val relay =
|
||||
relayArg?.let {
|
||||
RelayUrlNormalizer.normalizeOrNull(it) ?: return Output.error("bad_args", "--relay is not a valid relay URL")
|
||||
}
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val provider = ctx.requireUserHex(providerArg)
|
||||
val outbox = ctx.outboxRelays()
|
||||
|
||||
val latest =
|
||||
fetchLatestProviderList(ctx, ctx.identity.pubKeyHex, outbox, timeoutMs)
|
||||
?: return Output.error("not_found", "no kind:10040 provider list found for this account")
|
||||
|
||||
fun matches(tag: ServiceProviderTag) =
|
||||
tag.pubkey == provider &&
|
||||
(service == null || tag.service == service) &&
|
||||
(relay == null || tag.relayUrl == relay)
|
||||
|
||||
val publicMatches = latest.serviceProviders().filter(::matches)
|
||||
val privateMatches =
|
||||
latest
|
||||
.privateTags(ctx.signer)
|
||||
?.serviceProviders()
|
||||
.orEmpty()
|
||||
.filter(::matches)
|
||||
val toRemove = (publicMatches + privateMatches).distinct()
|
||||
|
||||
if (toRemove.isEmpty()) {
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"provider" to provider,
|
||||
"changed" to false,
|
||||
"removed" to emptyList<Any>(),
|
||||
"based_on" to latest.id,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// remove() strips the tag from both the public and the private set,
|
||||
// re-signing each round; only the final version is published.
|
||||
var event = latest
|
||||
for (tag in toRemove) {
|
||||
event = TrustProviderListEvent.remove(event, tag, ctx.signer)
|
||||
}
|
||||
|
||||
val ack = ctx.publish(event, outbox)
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"provider" to provider,
|
||||
"changed" to true,
|
||||
"removed" to toRemove.map { mapOf("service" to it.service.toValue(), "relay" to it.relayUrl.url) },
|
||||
"event_id" to event.id,
|
||||
"based_on" to latest.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank providers [USER] [--refresh] [--timeout SECS]`
|
||||
*
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
@@ -86,7 +87,7 @@ import okhttp3.Request
|
||||
*/
|
||||
object RelayCommands {
|
||||
private const val USAGE =
|
||||
"relay <outbox|inbox|nip65|dm|key-package|search|private|blocked|trusted|proxy|indexer|broadcast|feeds|add|remove|list|publish-lists|info> …"
|
||||
"relay <outbox|inbox|nip65|dm|key-package|search|private|blocked|trusted|proxy|indexer|broadcast|feeds|add|remove|list|publish-lists|info|probe> …"
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Flat buckets — a plain list of relay URLs, one Nostr replaceable kind.
|
||||
@@ -216,6 +217,7 @@ object RelayCommands {
|
||||
// `info` is also intercepted in Main before account resolution
|
||||
// (it needs no account); routed here too for when one exists.
|
||||
"info" -> info(rest)
|
||||
"probe" -> probe(dataDir, rest)
|
||||
"add" -> fanOut(dataDir, Args(rest), add = true)
|
||||
"remove", "rm" -> fanOut(dataDir, Args(rest), add = false)
|
||||
"outbox" -> facetVerb(dataDir, Facet.OUTBOX, rest)
|
||||
@@ -269,6 +271,94 @@ object RelayCommands {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// relay probe — the relay census (feeds the NIP-66 reachability cache)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `amy relay probe [--timeout SECS] [--concurrency N]` —
|
||||
* the relay census. Mass-connects the ENTIRE relay universe the local store knows
|
||||
* (every relay advertised in any stored kind:10002, deduped per host, plus
|
||||
* everything already in the reachability cache) in parallel waves with a no-op
|
||||
* REQ, so the "is this relay alive, and how slow?" wait is paid once, up front,
|
||||
* concurrently — then records per-relay verdicts with real measured `rtt-open`
|
||||
* into the NIP-66 reachability cache (kind:30166).
|
||||
*
|
||||
* Every reachability-aware command reads that cache to skip the dead set without
|
||||
* dialing it: `graperank crawl` also pre-connects the live set in one storm,
|
||||
* separating "working but slow" (kept; the crawler's patient park path waits for
|
||||
* them) from "not working" (skipped entirely). Typical flow the first time:
|
||||
* `graperank crawl --max-hops 2` (cheap, saves the relay lists) → `relay probe`
|
||||
* → full `graperank crawl`. (`graperank probe` is kept as an alias.)
|
||||
*/
|
||||
suspend fun probe(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
// Per probe WAVE, not per relay or total — a wave's stragglers are cut off
|
||||
// together when it elapses.
|
||||
val timeoutMs = args.longFlag("timeout", 15L) * 1000
|
||||
// Relays dialed at once; --relay-concurrency accepted as the alias the
|
||||
// graperank verbs spell it with.
|
||||
val waveSize = args.intFlag("concurrency", args.intFlag("relay-concurrency", Context.defaultPreconnectCap))
|
||||
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val cached = ctx.reachability.snapshot()
|
||||
val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead
|
||||
if (universe.isEmpty()) {
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"probed" to 0,
|
||||
"note" to "no relays known locally — run `amy graperank crawl` first to gather kind:10002 relay lists",
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
System.err.println(
|
||||
"[relay-probe] probing ${universe.size} relays in waves of $waveSize " +
|
||||
"(${timeoutMs / 1000}s per wave; open-files limit ${Context.maxFileDescriptors})",
|
||||
)
|
||||
val result =
|
||||
RelayProber(ctx.client) { System.err.println(it) }
|
||||
.probe(universe, timeoutMs, waveSize)
|
||||
|
||||
ctx.reachability.recordProbed(result.reachableRttMs(), result.deadRelays())
|
||||
|
||||
val rtts =
|
||||
result.reachable
|
||||
.map { it.rttOpenMs }
|
||||
.filter { it >= 0 }
|
||||
.sorted()
|
||||
|
||||
fun pct(p: Int): Long? = if (rtts.isEmpty()) null else rtts[(rtts.size - 1) * p / 100]
|
||||
val slowest =
|
||||
result.reachable
|
||||
.filter { it.rttOpenMs >= 0 }
|
||||
.sortedByDescending { it.rttOpenMs }
|
||||
.take(10)
|
||||
.map { mapOf("relay" to it.relay.url, "rtt_open_ms" to it.rttOpenMs) }
|
||||
val authWalled = result.reachable.count { it.error?.startsWith("closed:") == true }
|
||||
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"probed" to result.verdicts.size,
|
||||
"reachable" to result.reachable.size,
|
||||
"dead" to result.dead.size,
|
||||
"closed_by_policy" to authWalled,
|
||||
"elapsed_ms" to result.elapsedMs,
|
||||
"rtt_open_p50_ms" to pct(50),
|
||||
"rtt_open_p90_ms" to pct(90),
|
||||
"rtt_open_p99_ms" to pct(99),
|
||||
"slowest" to slowest,
|
||||
),
|
||||
)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Flat-bucket verbs
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
+37
-27
@@ -24,12 +24,16 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
|
||||
/**
|
||||
@@ -64,21 +68,39 @@ object ReactionAction {
|
||||
throw IllegalStateException("Cannot react publicly to a private rumor")
|
||||
}
|
||||
|
||||
// Handle custom emoji reactions (format: ":emoji_name:")
|
||||
val template =
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrlTag.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
ReactionEvent.build(emojiUrl, eventHint)
|
||||
} else {
|
||||
// Fallback to text if emoji decode fails
|
||||
ReactionEvent.build(reaction, eventHint)
|
||||
}
|
||||
} else {
|
||||
ReactionEvent.build(reaction, eventHint)
|
||||
}
|
||||
return signer.sign(buildPublicReaction(eventHint, reaction))
|
||||
}
|
||||
|
||||
return signer.sign(template)
|
||||
/**
|
||||
* Builds a public reaction template for [eventHint], decoding a custom-emoji
|
||||
* reaction when present and falling back to plain text otherwise.
|
||||
*
|
||||
* When the target is a NIP-29 group event (it carries an `h` tag), the
|
||||
* reaction copies that `h` tag so the like stays scoped to the group and
|
||||
* lands on the group's host relay — where the recipient's group-notification
|
||||
* subscription (`#p`=them + `#h`=their groups, kind 7 included) can match it.
|
||||
* Without the `h` tag the like is a plain kind-7 that the host-relay query
|
||||
* never sees, so a reaction to someone's group message would only reach them
|
||||
* on the off chance NIP-65 routing delivered it to one of their inbox relays
|
||||
* — never for a host-relay-only group. This mirrors how kind-9 replies carry
|
||||
* the `h` tag to be notifiable.
|
||||
*/
|
||||
private fun buildPublicReaction(
|
||||
eventHint: EventHintBundle<Event>,
|
||||
reaction: String,
|
||||
): EventTemplate<ReactionEvent> {
|
||||
val groupScope: TagArrayBuilder<ReactionEvent>.() -> Unit = {
|
||||
eventHint.event.groupId()?.let { hTag(it) }
|
||||
}
|
||||
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrlTag.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
return ReactionEvent.build(emojiUrl, eventHint, initializer = groupScope)
|
||||
}
|
||||
// Fallback to text if emoji decode fails
|
||||
}
|
||||
return ReactionEvent.build(reaction, eventHint, initializer = groupScope)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,19 +181,7 @@ object ReactionAction {
|
||||
)
|
||||
} else {
|
||||
// Public reaction
|
||||
val template =
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrlTag.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
ReactionEvent.build(emojiUrl, eventHint)
|
||||
} else {
|
||||
ReactionEvent.build(reaction, eventHint)
|
||||
}
|
||||
} else {
|
||||
ReactionEvent.build(reaction, eventHint)
|
||||
}
|
||||
|
||||
onPublic(signer.sign(template))
|
||||
onPublic(signer.sign(buildPublicReaction(eventHint, reaction)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -27,9 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
@@ -57,12 +60,46 @@ class ReactionActionTest {
|
||||
publicCalls++
|
||||
assertTrue(reaction.sig.isNotEmpty(), "public reaction must be signed")
|
||||
assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == note.id })
|
||||
assertFalse(
|
||||
reaction.tags.any { it.isNotEmpty() && it[0] == "h" },
|
||||
"a reaction to a non-group note must not carry an `h` tag",
|
||||
)
|
||||
},
|
||||
onPrivate = { fail("reaction to a public note must not be gift-wrapped") },
|
||||
)
|
||||
assertEquals(1, publicCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reactionToRelayGroupMessage_carriesTheGroupHTag() =
|
||||
runTest {
|
||||
// A NIP-29 group chat message: a kind-9 ChatEvent scoped by `h`.
|
||||
val groupId = "abcd1234"
|
||||
val groupMessage = aliceSigner.sign(ChatEvent.build("gm") { hTag(groupId) })
|
||||
|
||||
var publicCalls = 0
|
||||
ReactionAction.reactToWithGroupSupport(
|
||||
eventHint = EventHintBundle(groupMessage, null),
|
||||
reaction = "+",
|
||||
signer = bobSigner,
|
||||
onPublic = { reaction ->
|
||||
publicCalls++
|
||||
// Standard NIP-25 targeting …
|
||||
assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == groupMessage.id })
|
||||
assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "p" && it[1] == aliceSigner.pubKey })
|
||||
// … plus the group `h` tag copied from the target, so the like
|
||||
// stays in the group and the recipient's `#p`+`#h` host-relay
|
||||
// notification query can match it.
|
||||
assertTrue(
|
||||
reaction.tags.any { it.size >= 2 && it[0] == "h" && it[1] == groupId },
|
||||
"a reaction to a NIP-29 group message must copy the group's `h` tag",
|
||||
)
|
||||
},
|
||||
onPrivate = { fail("a public group message reaction must not be gift-wrapped") },
|
||||
)
|
||||
assertEquals(1, publicCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reactionToUnsealedRumor_isGiftWrappedToAllParticipants() =
|
||||
runTest {
|
||||
|
||||
+220
-95
@@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.experimental.graperank
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropyStoreSync
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
@@ -29,92 +32,165 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Publishes a set of GrapeRank scores as NIP-85 kind:30382 [ContactCardEvent]
|
||||
* trusted assertions (one `rank` card per scored user), reconciled against what
|
||||
* this provider key has already published so a repeat run only writes what moved.
|
||||
* Persists a set of GrapeRank scores as NIP-85 kind:30382 [ContactCardEvent]
|
||||
* trusted assertions in the local [IEventStore] — the durable, reusable form of a
|
||||
* score run — and pushes that local card set out to the operator's relays on
|
||||
* demand. The store is the source of truth; the two halves are separable:
|
||||
*
|
||||
* Reconciliation, given the desired `(target, rank)` set the scorer produced:
|
||||
* - **skip** a target whose stored card already carries the same rank string —
|
||||
* re-signing an unchanged card would churn a new event id for no client benefit;
|
||||
* - **upsert** a target whose rank changed (or that has no card yet), up to a
|
||||
* publish limit;
|
||||
* - **retract** every stored card whose target is no longer in the desired set
|
||||
* (it fell below the caller's cutoff, or dropped out of the graph) with a NIP-09
|
||||
* kind:5 deletion, batched so the frame stays under the ~64KB event cap.
|
||||
*
|
||||
* Transport-agnostic like [GrapeRankCrawler]: it reads prior cards from an
|
||||
* [IEventStore] and emits through an injected [publish] function (event + relays →
|
||||
* per-relay ack), so the store/relay wiring stays in the application while the
|
||||
* reconcile + card-construction logic is reusable (e.g. by the Android app).
|
||||
* - [reconcileLocal] runs after every scoring pass. It diffs the desired
|
||||
* `(target, rank)` set against the cards this provider key already holds in the
|
||||
* store, signs + inserts only what moved (an unchanged rank is skipped so no new
|
||||
* event id churns), and retracts every card whose target dropped out with a
|
||||
* NIP-09 kind:5. The store applies kind:5 on insert, so a retracted card
|
||||
* disappears locally while the deletion event remains as the durable tombstone
|
||||
* to propagate.
|
||||
* - [syncToRelays] is pure transport: a NIP-77 up-only reconcile of everything the
|
||||
* provider key authored (kind:30382 cards + kind:5 retractions) against each
|
||||
* relay, so the relay converges to the local set — nothing is re-signed, and a
|
||||
* card the relay lost (or never had) is restored. A relay that can't reconcile
|
||||
* gets the full local set blast-published instead (relays dedup by id).
|
||||
*/
|
||||
class GrapeRankPublisher(
|
||||
private val store: IEventStore,
|
||||
private val publish: suspend (Event, Set<NormalizedRelayUrl>) -> Map<NormalizedRelayUrl, Boolean>,
|
||||
private val log: (String) -> Unit = {},
|
||||
) {
|
||||
/** Outcome counts for one reconcile: what was written, retracted, and skipped. */
|
||||
class Result(
|
||||
val published: Int,
|
||||
val publishRejected: Int,
|
||||
val deleted: Int,
|
||||
val deleteRejected: Int,
|
||||
val skippedUnchanged: Int,
|
||||
/** Changed cards beyond [publishLimit] that were not upserted this run. */
|
||||
val truncated: Int,
|
||||
/** Outcome of one [reconcileLocal]: what was signed, retracted, and left alone. */
|
||||
class LocalResult(
|
||||
/** New or rank-changed cards signed and inserted into the store. */
|
||||
val signed: Int,
|
||||
/** Stale cards retracted (kind:5) because their target left the desired set. */
|
||||
val retracted: Int,
|
||||
/** Desired cards whose stored rank already matched — no new signature. */
|
||||
val unchanged: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reconcile the desired [scored] `(target, rank)` set (the caller has already
|
||||
* applied any rank cutoff) against the cards [providerPubkey] previously
|
||||
* published, then upsert the changes and retract the stale cards, all signed by
|
||||
* [providerSigner]. At most [publishLimit] changed cards are upserted per run.
|
||||
* applied any rank cutoff) into the local store, signed by [providerSigner]:
|
||||
* upsert the changed cards, retract the stale ones, skip the unchanged rest.
|
||||
* Every card and retraction is stamped [createdAt], so a replacement is
|
||||
* strictly newer than what it displaces.
|
||||
*/
|
||||
suspend fun reconcileAndPublish(
|
||||
suspend fun reconcileLocal(
|
||||
providerSigner: NostrSigner,
|
||||
providerPubkey: HexKey,
|
||||
scored: List<Pair<HexKey, Int>>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
publishLimit: Int,
|
||||
publishConcurrency: Int = PUBLISH_CONCURRENCY,
|
||||
): Result {
|
||||
// Newest card per target this provider already published (read back from
|
||||
// the store, which every published card was persisted to).
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): LocalResult {
|
||||
val existing = existingCards(providerPubkey)
|
||||
val publishableTargets = scored.mapTo(HashSet()) { it.first }
|
||||
val desiredTargets = scored.mapTo(HashSet()) { it.first }
|
||||
|
||||
// Upsert publishable targets whose rank tag STRING would change (or that
|
||||
// have no card yet). RankTag.assemble writes rank.toString(), so we diff
|
||||
// that exact string — an unchanged score is skipped so clients only sync
|
||||
// ranks that moved.
|
||||
// Upsert targets whose rank tag STRING would change (or that have no card
|
||||
// yet). RankTag.assemble writes rank.toString(), so we diff that exact
|
||||
// string — an unchanged score never produces a new signature.
|
||||
val changed = scored.filter { (target, rank) -> existing[target]?.let(::rankTagValue) != rank.toString() }
|
||||
val toUpsert = changed.take(publishLimit)
|
||||
|
||||
// Retract existing cards whose target is no longer publishable — it dropped
|
||||
// out of the graph, or fell below the caller's cutoff. We won't leave a
|
||||
// stale assertion standing.
|
||||
val toDelete = existing.filterKeys { it !in publishableTargets }.values.toList()
|
||||
// Retract cards whose target is no longer desired — it dropped out of the
|
||||
// graph, or fell below the caller's cutoff. No stale assertion is left standing.
|
||||
val toRetract = existing.filterKeys { it !in desiredTargets }.values.toList()
|
||||
|
||||
val (ok, rejected) = publishCards(providerSigner, toUpsert, relays, publishConcurrency)
|
||||
val (deleted, deleteRejected) = publishDeletions(providerSigner, toDelete, relays)
|
||||
val signed = signAndInsertCards(providerSigner, changed, createdAt)
|
||||
val retracted = insertRetractions(providerSigner, toRetract, createdAt)
|
||||
|
||||
return Result(
|
||||
published = ok,
|
||||
publishRejected = rejected,
|
||||
deleted = deleted,
|
||||
deleteRejected = deleteRejected,
|
||||
skippedUnchanged = scored.size - changed.size,
|
||||
truncated = (changed.size - toUpsert.size).coerceAtLeast(0),
|
||||
return LocalResult(
|
||||
signed = signed,
|
||||
retracted = retracted,
|
||||
unchanged = scored.size - changed.size,
|
||||
)
|
||||
}
|
||||
|
||||
/** Per-relay outcome of a [syncToRelays]. */
|
||||
class RelaySyncResult(
|
||||
val relay: NormalizedRelayUrl,
|
||||
/** Events the NIP-77 reconcile found missing on the relay and uploaded. */
|
||||
val uploaded: Int,
|
||||
/** Events blast-published because the relay couldn't reconcile. */
|
||||
val fallbackPublished: Int,
|
||||
val fallbackRejected: Int,
|
||||
/** The negentropy failure that triggered the fallback, or null when it reconciled. */
|
||||
val error: String?,
|
||||
) {
|
||||
/** True when the relay now converged to the local set by either path. */
|
||||
val ok: Boolean get() = error == null || (fallbackPublished > 0 && fallbackRejected == 0)
|
||||
}
|
||||
|
||||
/** Aggregate outcome of a [syncToRelays]. */
|
||||
class SyncResult(
|
||||
/** kind:30382 cards this provider key holds locally (the set being mirrored). */
|
||||
val cards: Int,
|
||||
/** kind:5 retraction events this provider key holds locally. */
|
||||
val deletions: Int,
|
||||
val perRelay: List<RelaySyncResult>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The newest kind:30382 card [providerPubkey] published per target, read from
|
||||
* the store (every card [publish] sends is persisted first, so on repeat runs
|
||||
* this reflects what is already out there).
|
||||
* Make every relay in [relays] converge to the local card set of
|
||||
* [providerPubkey]: one NIP-77 up-only reconcile per relay over the provider's
|
||||
* kind:30382 + kind:5 (nothing is downloaded — the store is the source of
|
||||
* truth), falling back to blast-publishing the full local set when a relay
|
||||
* can't reconcile. Best-effort per relay; a failure never aborts the others.
|
||||
*/
|
||||
suspend fun syncToRelays(
|
||||
client: INostrClient,
|
||||
providerPubkey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
relayConcurrency: Int = 4,
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
publishTimeoutSecs: Long = 15,
|
||||
): SyncResult {
|
||||
val cards = store.query<Event>(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(providerPubkey)))
|
||||
val deletions = store.query<Event>(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(providerPubkey)))
|
||||
|
||||
val filter = Filter(kinds = listOf(ContactCardEvent.KIND, DeletionEvent.KIND), authors = listOf(providerPubkey))
|
||||
val groups =
|
||||
NegentropyStoreSync(
|
||||
client = client,
|
||||
store = store,
|
||||
config =
|
||||
NegentropyStoreSync.Config(
|
||||
// Up-only: the relay must converge to the store, never the
|
||||
// reverse — pulling a stale card back down would resurrect a
|
||||
// locally-retracted assertion. The kind:5s ride the same
|
||||
// filter, so deletions propagate as ordinary uploads.
|
||||
down = false,
|
||||
up = true,
|
||||
syncDeletions = false,
|
||||
// The paged fallback DOWNLOADS the filter — wrong direction
|
||||
// for a push. Failed groups get [blastPublish] instead.
|
||||
pageFallback = false,
|
||||
concurrency = relayConcurrency,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
publishTimeoutSecs = publishTimeoutSecs,
|
||||
),
|
||||
log = log,
|
||||
).sync(relays.associateWith { listOf(filter) })
|
||||
|
||||
val perRelay =
|
||||
groups.map { g ->
|
||||
if (g.error == null) {
|
||||
RelaySyncResult(g.relay, uploaded = g.uploaded, fallbackPublished = 0, fallbackRejected = 0, error = null)
|
||||
} else {
|
||||
log("[graperank] ${g.relay.url}: negentropy failed (${g.error}) — publishing the full local set instead")
|
||||
val (ok, rejected) = blastPublish(client, cards + deletions, g.relay, publishTimeoutSecs)
|
||||
RelaySyncResult(g.relay, uploaded = g.uploaded, fallbackPublished = ok, fallbackRejected = rejected, error = g.error)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncResult(cards = cards.size, deletions = deletions.size, perRelay = perRelay)
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest kind:30382 card [providerPubkey] holds per target in the local
|
||||
* store. Locally-retracted cards never show up — inserting their kind:5
|
||||
* removed them — so a target can be re-carded later without fighting a ghost.
|
||||
*/
|
||||
private suspend fun existingCards(providerPubkey: HexKey): Map<HexKey, ContactCardEvent> =
|
||||
store
|
||||
@@ -129,7 +205,7 @@ class GrapeRankPublisher(
|
||||
/**
|
||||
* The raw `rank` tag value string on a card — exactly what a client diffs, so an
|
||||
* unchanged score never produces a new signature. Our cards carry only a `rank`
|
||||
* tag (plus the d-tag target), so this one value decides whether a re-publish
|
||||
* tag (plus the d-tag target), so this one value decides whether a re-sign
|
||||
* would differ.
|
||||
*/
|
||||
private fun rankTagValue(card: ContactCardEvent): String? =
|
||||
@@ -137,63 +213,112 @@ class GrapeRankPublisher(
|
||||
if (tag.size > 1 && tag[0] == RankTag.TAG_NAME) tag[1] else null
|
||||
}
|
||||
|
||||
/** Build + publish one kind:30382 card per (target, rank), bounded-concurrently. */
|
||||
private suspend fun publishCards(
|
||||
/**
|
||||
* Build + sign one kind:30382 card per (target, rank) — fanned out on
|
||||
* [Dispatchers.Default], since id-hash + Schnorr sign is CPU-bound — and insert
|
||||
* each into the store. Batched so a whole-network card set never materializes
|
||||
* in memory at once. Returns how many the store accepted.
|
||||
*/
|
||||
private suspend fun signAndInsertCards(
|
||||
signer: NostrSigner,
|
||||
cards: List<Pair<HexKey, Int>>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
concurrency: Int,
|
||||
): Pair<Int, Int> {
|
||||
var published = 0
|
||||
var rejected = 0
|
||||
for (batch in cards.chunked(concurrency)) {
|
||||
val acks =
|
||||
createdAt: Long,
|
||||
): Int {
|
||||
if (cards.isEmpty()) return 0
|
||||
var inserted = 0
|
||||
for (batch in cards.chunked(SIGN_BATCH)) {
|
||||
val signedBatch =
|
||||
coroutineScope {
|
||||
batch
|
||||
.map { (pubkey, rank) ->
|
||||
async {
|
||||
val card =
|
||||
ContactCardEvent.create(
|
||||
targetUser = pubkey,
|
||||
signer = signer,
|
||||
publicInitializer = { add(RankTag.assemble(rank)) },
|
||||
)
|
||||
publish(card, relays)
|
||||
.map { (target, rank) ->
|
||||
async(Dispatchers.Default) {
|
||||
ContactCardEvent.create(
|
||||
targetUser = target,
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
publicInitializer = { add(RankTag.assemble(rank)) },
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
for (ack in acks) {
|
||||
if (ack.values.any { it }) published++ else rejected++
|
||||
for (card in signedBatch) {
|
||||
if (insertQuietly(card)) inserted++
|
||||
}
|
||||
}
|
||||
return published to rejected
|
||||
return inserted
|
||||
}
|
||||
|
||||
/**
|
||||
* Retract stale cards with NIP-09 kind:5 deletions signed by [signer] (the same
|
||||
* key that signed the cards). Batches [DELETE_PER_EVENT] addressable coordinates
|
||||
* per deletion so the kind:5 frame stays under the ~64KB event cap; each carries
|
||||
* the card's `a` tag (30382:provider:target), so re-publishing a newer version
|
||||
* later isn't blocked. Returns (deleted, rejected) card counts.
|
||||
* key that signed the cards), inserted into the store — which applies them, so
|
||||
* the retracted cards vanish locally and only the tombstone remains to sync.
|
||||
* Batches [DELETE_PER_EVENT] addressable coordinates per deletion so the kind:5
|
||||
* frame stays under the ~64KB event cap; each carries the card's `a` tag
|
||||
* (30382:provider:target), so re-publishing a newer version later isn't blocked.
|
||||
* Returns how many cards were retracted.
|
||||
*/
|
||||
private suspend fun publishDeletions(
|
||||
private suspend fun insertRetractions(
|
||||
signer: NostrSigner,
|
||||
cards: List<ContactCardEvent>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): Pair<Int, Int> {
|
||||
if (cards.isEmpty()) return 0 to 0
|
||||
var deleted = 0
|
||||
var rejected = 0
|
||||
createdAt: Long,
|
||||
): Int {
|
||||
if (cards.isEmpty()) return 0
|
||||
var retracted = 0
|
||||
for (chunk in cards.chunked(DELETE_PER_EVENT)) {
|
||||
val event = signer.sign(DeletionEvent.build(chunk))
|
||||
val ack = publish(event, relays)
|
||||
if (ack.values.any { it }) deleted += chunk.size else rejected += chunk.size
|
||||
val deletion = signer.sign(DeletionEvent.build(chunk, createdAt))
|
||||
if (insertQuietly(deletion)) retracted += chunk.size
|
||||
}
|
||||
return deleted to rejected
|
||||
return retracted
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert without letting a single-row failure abort the whole reconcile. The
|
||||
* one expected rejection is the store's own deletion guard (a re-carded target
|
||||
* whose kind:5 landed in the same second); anything else is logged. Returns
|
||||
* whether the store accepted the event.
|
||||
*/
|
||||
private suspend fun insertQuietly(event: Event): Boolean =
|
||||
try {
|
||||
store.insert(event)
|
||||
true
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("[graperank] store rejected ${event.kind}/${event.id.take(8)}: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for a relay without working NIP-77: publish every event of the local
|
||||
* set individually (the relay dedups by id), bounded-concurrently. Returns
|
||||
* (accepted, rejected) event counts.
|
||||
*/
|
||||
private suspend fun blastPublish(
|
||||
client: INostrClient,
|
||||
events: List<Event>,
|
||||
relay: NormalizedRelayUrl,
|
||||
publishTimeoutSecs: Long,
|
||||
): Pair<Int, Int> {
|
||||
var ok = 0
|
||||
var rejected = 0
|
||||
val relaySet = setOf(relay)
|
||||
for (batch in events.chunked(PUBLISH_CONCURRENCY)) {
|
||||
val acks =
|
||||
coroutineScope {
|
||||
batch.map { event -> async { client.publishAndConfirmDetailed(event, relaySet, publishTimeoutSecs) } }.awaitAll()
|
||||
}
|
||||
for (ack in acks) {
|
||||
if (ack.values.any { it }) ok++ else rejected++
|
||||
}
|
||||
}
|
||||
return ok to rejected
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Concurrent card publishes when upserting. */
|
||||
/** Cards signed per batch — bounds live event objects, not parallelism. */
|
||||
const val SIGN_BATCH = 1024
|
||||
|
||||
/** Concurrent per-event publishes in the [blastPublish] fallback. */
|
||||
const val PUBLISH_CONCURRENCY = 16
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
@@ -88,6 +89,7 @@ class ReactionEvent(
|
||||
reaction: String,
|
||||
reactedTo: EventHintBundle<Event>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ReactionEvent>.() -> Unit = {},
|
||||
) = eventTemplate<ReactionEvent>(KIND, reaction, createdAt) {
|
||||
eTag(reactedTo.toETag())
|
||||
if (reactedTo.event is AddressableEvent) {
|
||||
@@ -95,12 +97,14 @@ class ReactionEvent(
|
||||
}
|
||||
pTag(reactedTo.event.pubKey, reactedTo.relay)
|
||||
kind(reactedTo.event.kind)
|
||||
initializer()
|
||||
}
|
||||
|
||||
fun build(
|
||||
reaction: EmojiUrlTag,
|
||||
reactedTo: EventHintBundle<Event>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ReactionEvent>.() -> Unit = {},
|
||||
) = eventTemplate<ReactionEvent>(KIND, reaction.toContentEncode(), createdAt) {
|
||||
eTag(reactedTo.toETag())
|
||||
if (reactedTo.event is AddressableEvent) {
|
||||
@@ -109,6 +113,7 @@ class ReactionEvent(
|
||||
pTag(reactedTo.event.pubKey, reactedTo.relay)
|
||||
kind(reactedTo.event.kind)
|
||||
emoji(reaction)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.quartz.experimental.graperank
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* [GrapeRankPublisher.reconcileLocal] is what makes a score run durable: the
|
||||
* local store must end up holding exactly the desired card set — changed ranks
|
||||
* re-signed, unchanged ranks untouched (no event-id churn), dropped targets
|
||||
* retracted via kind:5 (which the store applies on insert) with the tombstone
|
||||
* kept for a later relay sync.
|
||||
*/
|
||||
class GrapeRankPublisherTest {
|
||||
private fun hexKey(n: Int): String = n.toString(16).padStart(64, '0')
|
||||
|
||||
private suspend fun cardsByTarget(
|
||||
store: IEventStore,
|
||||
provider: String,
|
||||
): Map<String, Int?> =
|
||||
store
|
||||
.query<Event>(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider)))
|
||||
.filterIsInstance<ContactCardEvent>()
|
||||
.associate { it.aboutUser() to it.rank() }
|
||||
|
||||
@Test
|
||||
fun reconcileLocalUpsertsSkipsAndRetracts() =
|
||||
runBlocking {
|
||||
val store = EventStore(null)
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val provider = signer.pubKey
|
||||
val publisher = GrapeRankPublisher(store)
|
||||
|
||||
val a = hexKey(0xA)
|
||||
val b = hexKey(0xB)
|
||||
val c = hexKey(0xC)
|
||||
|
||||
// First run: every card is new.
|
||||
val r1 = publisher.reconcileLocal(signer, provider, listOf(a to 50, b to 10), createdAt = 1_000L)
|
||||
assertEquals(2, r1.signed)
|
||||
assertEquals(0, r1.unchanged)
|
||||
assertEquals(0, r1.retracted)
|
||||
assertEquals(mapOf(a to 50, b to 10), cardsByTarget(store, provider))
|
||||
val firstIds =
|
||||
store
|
||||
.query<Event>(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider)))
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
|
||||
// Same ranks again: nothing is re-signed, no event id churns.
|
||||
val r2 = publisher.reconcileLocal(signer, provider, listOf(a to 50, b to 10), createdAt = 2_000L)
|
||||
assertEquals(0, r2.signed)
|
||||
assertEquals(2, r2.unchanged)
|
||||
assertEquals(0, r2.retracted)
|
||||
val secondIds =
|
||||
store
|
||||
.query<Event>(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider)))
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
assertEquals(firstIds, secondIds)
|
||||
|
||||
// a's rank moved, b dropped out, c is new: a + c signed, b retracted.
|
||||
val r3 = publisher.reconcileLocal(signer, provider, listOf(a to 60, c to 5), createdAt = 3_000L)
|
||||
assertEquals(2, r3.signed)
|
||||
assertEquals(0, r3.unchanged)
|
||||
assertEquals(1, r3.retracted)
|
||||
|
||||
// The store converged to exactly the desired set — b's card is gone…
|
||||
assertEquals(mapOf(a to 60, c to 5), cardsByTarget(store, provider))
|
||||
|
||||
// …but its kind:5 tombstone remains, ready to sync to relays.
|
||||
val deletions = store.query<Event>(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(provider)))
|
||||
assertEquals(1, deletions.size)
|
||||
|
||||
store.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retractedTargetCanBeReCardedLater() =
|
||||
runBlocking {
|
||||
val store = EventStore(null)
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val provider = signer.pubKey
|
||||
val publisher = GrapeRankPublisher(store)
|
||||
|
||||
val a = hexKey(0xA)
|
||||
|
||||
publisher.reconcileLocal(signer, provider, listOf(a to 40), createdAt = 1_000L)
|
||||
publisher.reconcileLocal(signer, provider, emptyList(), createdAt = 2_000L)
|
||||
assertEquals(emptyMap(), cardsByTarget(store, provider))
|
||||
|
||||
// A NEWER card outranks the older kind:5 (NIP-09 deletions only cover
|
||||
// versions up to their created_at), so the target comes back cleanly.
|
||||
val r = publisher.reconcileLocal(signer, provider, listOf(a to 45), createdAt = 3_000L)
|
||||
assertEquals(1, r.signed)
|
||||
assertEquals(mapOf(a to 45), cardsByTarget(store, provider))
|
||||
|
||||
store.close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user