feat(cli): amy geochat listen/send for Bitchat location-channel interop

Adds a thin `amy geochat` verb over the quartz + commons geohash-chat code so the
interop path is exercisable headlessly (and against a real Bitchat client):
`send` builds/signs/mines/publishes a kind-20000 message with the per-geohash
ephemeral identity; `listen` holds a live subscription (kinds 20000/20001 are
ephemeral, so relays broadcast but don't store them) and reports messages plus
distinct present pubkeys; `keys` shows the derived per-geohash pubkey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
This commit is contained in:
Claude
2026-07-15 16:17:36 +00:00
parent ba93dd3564
commit b6729bec00
2 changed files with 279 additions and 0 deletions
@@ -38,6 +38,7 @@ 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.GeochatCommands
import com.vitorpamplona.amethyst.cli.commands.GiftCommands
import com.vitorpamplona.amethyst.cli.commands.GitCommands
import com.vitorpamplona.amethyst.cli.commands.GrapeRankCommand
@@ -262,6 +263,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
"marmot" -> marmotDispatch(dataDir, tail)
"relaygroup" -> RelayGroupCommands.dispatch(dataDir, tail)
"dm" -> DmCommands.dispatch(dataDir, tail)
"geochat" -> GeochatCommands.dispatch(dataDir, tail)
"profile" -> ProfileCommands.dispatch(dataDir, tail)
"notes" -> NotesCommands.dispatch(dataDir, tail)
"pow" -> PowCommands.dispatch(dataDir, tail)
@@ -0,0 +1,277 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.service.georelay.GeoRelayCsvLoader
import com.vitorpamplona.amethyst.commons.service.georelay.GeoRelayDirectory
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashPresenceEvent
import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
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.NostrSignerSync
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.util.concurrent.CopyOnWriteArrayList
/**
* `amy geochat <listen|send|keys>` — Bitchat-interoperable public geohash chat
* (location channels) over Nostr.
*
* Messages are ephemeral kind-20000 events tagged `["g", geohash]`, signed with a
* per-geohash throwaway identity, and routed to the relays geographically nearest
* the cell (the [GeoRelayDirectory]). This verb drives the same quartz + commons
* code the apps use, so it doubles as an interop harness against a real Bitchat
* client: run `listen` in one place, `send` (or a Bitchat device) in another.
*
* Because the events are ephemeral, relays broadcast them live but do not store
* them — `listen` therefore holds an open subscription for `--seconds` and reports
* everything that arrives in that window.
*/
object GeochatCommands {
private const val DEFAULT_LISTEN_SECONDS = 30L
private const val DEFAULT_LIMIT = 500
private const val DEFAULT_POW_TIMEOUT_SECS = 5L
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"geochat",
tail,
"geochat <listen|send|keys> …",
mapOf(
"listen" to { rest -> listen(dataDir, rest) },
"send" to { rest -> send(dataDir, rest) },
"keys" to { rest -> keys(rest) },
),
)
/**
* `amy geochat listen <geohash> [--seconds N] [--limit N] [--relay wss://… ...] [--no-fetch]`
* Holds a live subscription to kinds 20000/20001 for the cell and reports the
* chat messages + distinct present pubkeys seen in the window.
*/
private suspend fun listen(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val geohash =
args.positional.firstOrNull()?.lowercase()
?: return Output.error("bad_args", "geochat listen <geohash> [--seconds N] [--limit N] [--relay …] [--no-fetch]")
if (!isGeohash(geohash)) return Output.error("bad_args", "not a geohash: $geohash")
val seconds = args.longFlag("seconds", DEFAULT_LISTEN_SECONDS)
val limit = args.intFlag("limit", DEFAULT_LIMIT)
val relays = resolveRelays(args, geohash)
if (relays.isEmpty()) return Output.error("no_relays", "no relays for geohash $geohash (directory empty / bad --relay)")
val since = TimeUtils.now() - seconds.coerceAtLeast(1)
val filter = Filter(kinds = listOf(GeohashChatEvent.KIND, GeohashPresenceEvent.KIND), tags = mapOf("g" to listOf(geohash)), since = since, limit = limit)
val collected = CopyOnWriteArrayList<Event>()
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.verify()) collected.add(event)
}
}
try {
ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener)
delay(seconds * 1000)
} finally {
ctx.client.unsubscribe(subId)
}
}
// Dedup by id (the same event can arrive from several relays).
val unique = collected.associateBy { it.id }.values
val messages =
unique
.filterIsInstance<GeohashChatEvent>()
.sortedBy { it.createdAt }
.map { messageJson(it) }
val presentPubkeys = unique.filter { it.kind == GeohashChatEvent.KIND || it.kind == GeohashPresenceEvent.KIND }.map { it.pubKey }.toSet()
Output.emit(
mapOf(
"geohash" to geohash,
"relays" to relays.map { it.url },
"listened_seconds" to seconds,
"participants" to presentPubkeys.size,
"message_count" to messages.size,
"messages" to messages,
),
)
return 0
}
/**
* `amy geochat send <geohash> <message> [--nick NAME] [--teleport] [--pow BITS]
* [--seed HEX] [--relay wss://… ...] [--no-fetch]`
* Signs a kind-20000 message with the per-geohash ephemeral identity (derived
* from [--seed], or a random one for this run) and publishes it to the cell's
* relays.
*/
private suspend fun send(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val geohash = args.positional.getOrNull(0)?.lowercase()
val message = args.positional.getOrNull(1)
if (geohash == null || message == null) {
return Output.error("bad_args", "geochat send <geohash> <message> [--nick NAME] [--teleport] [--pow BITS] [--seed HEX] [--relay …] [--no-fetch]")
}
if (!isGeohash(geohash)) return Output.error("bad_args", "not a geohash: $geohash")
val seed = resolveSeed(args) ?: return Output.error("bad_args", "--seed must be 64 hex chars (32 bytes)")
val keyPair = GeohashKeyDerivation.deriveKeyPair(seed, geohash)
val signer = NostrSignerSync(keyPair)
val nick = args.flag("nick")?.takeIf { it.isNotBlank() }
val teleported = args.bool("teleport")
val powBits = args.intFlag("pow", 0)
var template = GeohashChatEvent.build(message, geohash, nickname = nick, teleported = teleported)
if (powBits > 0) {
val deadline = System.nanoTime() + args.longFlag("pow-timeout", DEFAULT_POW_TIMEOUT_SECS) * 1_000_000_000L
template =
withContext(Dispatchers.Default) {
PoWMiner.mine(template, keyPair.pubKey.toHexKey(), powBits, defaultThreads()) { System.nanoTime() < deadline }
}
}
val event = signer.sign(template)
val relays = resolveRelays(args, geohash)
if (relays.isEmpty()) return Output.error("no_relays", "no relays for geohash $geohash (directory empty / bad --relay)")
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val acks = ctx.publish(event, relays.toSet())
Output.emit(
mapOf(
"id" to event.id,
"pubkey" to event.pubKey,
"geohash" to geohash,
"nickname" to nick,
"teleported" to teleported,
"pow" to PoWRankEvaluator.calculatePowRankOf(event.id),
"content" to message,
"published_to" to acks.filterValues { it }.keys.map { it.url },
"rejected_by" to acks.filterValues { !it }.keys.map { it.url },
),
)
}
return 0
}
/**
* `amy geochat keys <geohash> [--seed HEX]` — the per-geohash pubkey derived
* for the (seed, geohash) pair. Useful to check identity stability or to
* address a geohash DM.
*/
private fun keys(rest: Array<String>): Int {
val args = Args(rest)
val geohash = args.positional.firstOrNull()?.lowercase() ?: return Output.error("bad_args", "geochat keys <geohash> [--seed HEX]")
if (!isGeohash(geohash)) return Output.error("bad_args", "not a geohash: $geohash")
val seed = resolveSeed(args) ?: return Output.error("bad_args", "--seed must be 64 hex chars (32 bytes)")
val keyPair = GeohashKeyDerivation.deriveKeyPair(seed, geohash)
Output.emit(
mapOf(
"geohash" to geohash,
"pubkey" to keyPair.pubKey.toHexKey(),
"seed" to seed.toHexKey(),
),
)
return 0
}
// ------------------------------------------------------------------
/** Explicit `--relay` list wins; otherwise the closest relays from the (optionally refreshed) directory. */
private suspend fun resolveRelays(
args: Args,
geohash: String,
): List<NormalizedRelayUrl> {
val explicit = args.flags["relay"]?.let { listOfNotNull(RelayUrlNormalizer.normalizeOrNull(it)) } ?: emptyList()
val allExplicit = (explicit + args.positional.mapNotNull { if (it.startsWith("wss://") || it.startsWith("ws://")) RelayUrlNormalizer.normalizeOrNull(it) else null })
if (allExplicit.isNotEmpty()) return allExplicit.distinct()
val directory = GeoRelayDirectory()
if (!args.bool("no-fetch")) {
runCatching { GeoRelayCsvLoader { OkHttpClient() }.refresh(directory) }
}
return directory.closestRelays(geohash)
}
/** `--seed HEX` (32 bytes), else a fresh random per-run seed. */
private fun resolveSeed(args: Args): ByteArray? {
val hex = args.flag("seed") ?: return RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE)
if (hex.length != GeohashKeyDerivation.SEED_SIZE * 2 || !Hex.isHex(hex)) return null
return hex.hexToByteArray()
}
private fun messageJson(event: GeohashChatEvent): Map<String, Any?> =
mapOf(
"id" to event.id,
"pubkey" to event.pubKey,
"nickname" to event.nickname(),
"teleported" to event.isTeleported(),
"content" to event.content,
"created_at" to event.createdAt,
"pow" to PoWRankEvaluator.calculatePowRankOf(event.id),
)
private fun isGeohash(s: String): Boolean = s.isNotEmpty() && s.length <= 12 && s.all { it in GEOHASH_ALPHABET }
private fun defaultThreads(): Int = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private const val GEOHASH_ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz"
}