From b6729bec00deb99f2d05a40af420febaa6bb7c75 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:17:36 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV --- .../com/vitorpamplona/amethyst/cli/Main.kt | 2 + .../amethyst/cli/commands/GeochatCommands.kt | 277 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index b627e9d6d9..8ec28dac1c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt new file mode 100644 index 0000000000..d37489a961 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt @@ -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 ` — 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, + ): Int = + route( + "geochat", + tail, + "geochat …", + mapOf( + "listen" to { rest -> listen(dataDir, rest) }, + "send" to { rest -> send(dataDir, rest) }, + "keys" to { rest -> keys(rest) }, + ), + ) + + /** + * `amy geochat listen [--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, + ): Int { + val args = Args(rest) + val geohash = + args.positional.firstOrNull()?.lowercase() + ?: return Output.error("bad_args", "geochat listen [--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() + 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?, + ) { + 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() + .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 [--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, + ): 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 [--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 [--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): Int { + val args = Args(rest) + val geohash = args.positional.firstOrNull()?.lowercase() ?: return Output.error("bad_args", "geochat keys [--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 { + 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 = + 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" +}