From 1d6de8ba72993844e52dccc42f84412d8b1854c7 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 17:40:01 -0400 Subject: [PATCH] Shell peer: send and read the 0.32 kinds, and a bot mode Test harness only. Cards, events and answers, games (replayed with the games engine), photo stories with edits and take-downs, stickers and directory shares, all built with the engine's own encoders. The bot polls, logs every envelope it opens, and runs commands dropped in a folder. Co-Authored-By: Claude Opus 5.5 --- interop/build.gradle.kts | 2 + .../main/kotlin/io/flamenet/interop/Peer.kt | 25 +- .../kotlin/io/flamenet/interop/PeerKinds.kt | 514 ++++++++++++++++++ 3 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 interop/src/main/kotlin/io/flamenet/interop/PeerKinds.kt diff --git a/interop/build.gradle.kts b/interop/build.gradle.kts index 61e4866..5b4f731 100644 --- a/interop/build.gradle.kts +++ b/interop/build.gradle.kts @@ -14,6 +14,8 @@ repositories { mavenCentral() } dependencies { implementation(project(":engine")) implementation(project(":client")) + // The shell peer replays game logs with the app's own rules engine. + implementation(project(":games")) implementation("org.json:json:20240303") } diff --git a/interop/src/main/kotlin/io/flamenet/interop/Peer.kt b/interop/src/main/kotlin/io/flamenet/interop/Peer.kt index e4628c1..49d497d 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Peer.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Peer.kt @@ -44,7 +44,7 @@ object Peer { @JvmStatic fun main(args: Array) { if (args.size < 2) { - System.err.println("usage: peer [...]") + System.err.println("usage: peer [...]") kotlin.system.exitProcess(2) } val stateFile = File(args[0]) @@ -70,8 +70,31 @@ object Peer { val conversations = Conversations(identity, vault, relay) .also { it.myUserId = state.getLong("userId") } + // The 0.32 kinds, and the long-running correspondent (test harness). + val kinds = PeerKinds(stateFile, state, relay, api, conversations) when (command) { + "claim" -> { + println(kinds.claim()) + } + + /** One 0.32 command, as JSON: `peer state.json do '{"cmd":"card",...}'`. */ + "do" -> { + println(kinds.run(JSONObject(args.drop(2).joinToString(" ")))) + } + + /** Poll, describing every envelope in full. */ + "inspect" -> { + val out = org.json.JSONArray() + conversations.poll().forEach { out.put(kinds.describe(it)) } + println(JSONObject().put("messages", out)) + } + + /** `peer state.json bot [seconds]` */ + "bot" -> { + kinds.bot(File(args[2]), args.getOrNull(3)?.toIntOrNull() ?: 3600) + } + "whoami" -> { println( JSONObject() diff --git a/interop/src/main/kotlin/io/flamenet/interop/PeerKinds.kt b/interop/src/main/kotlin/io/flamenet/interop/PeerKinds.kt new file mode 100644 index 0000000..f2fae29 --- /dev/null +++ b/interop/src/main/kotlin/io/flamenet/interop/PeerKinds.kt @@ -0,0 +1,514 @@ +package io.flamenet.interop + +import io.flamenet.client.AccountIdentity +import io.flamenet.client.Conversations +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.Content +import io.flamenet.games.GameType +import io.flamenet.games.Games +import io.flamenet.games.Outcome +import io.flamenet.games.ReplayResult +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.SecureRandom +import java.util.UUID + +/** + * The 0.32 kinds for the shell peer: cards, events and answers, games, photo + * stories with their edits and take-downs, stickers and directory shares. + * + * Test harness only. Every envelope is built by the engine's own encoders + * (`Content.card`, `Content.event`, ...), so what the other app receives is + * the Android app's bytes, not a copy of them written here. + * + * `bot` is a long-running correspondent: it polls, writes one line per + * envelope it opens, and carries out commands dropped into a folder as + * `.cmd.json`, answering each with `.done.json`. That lets a UI + * test on the other platform say "send the sealed card now" without a second + * device. + */ +internal class PeerKinds( + private val stateFile: File, + private val state: JSONObject, + private val relay: RelayClient, + private val api: MessengerApi, + private val conversations: Conversations, +) { + private val myUserId = state.getLong("userId") + + /** What this peer knows about each game: its type, the side it plays, the log. */ + private data class Track( + val type: String, + val mine: String, + var log: List, + var ending: String? = null, + var endedBy: String? = null, + ) + + private val games = HashMap() + + /** Events this peer organised, so a revision can carry the whole event. */ + private val events = HashMap() + + /** The last `n` sent for each event (DESIGN 7). */ + private val answered = HashMap() + + private val handles = HashMap() + + /** Every envelope opened by `bot`, in order, as `describe` wrote it. */ + private val received = mutableListOf() + + /** Where the last `mark` left the received list. */ + private var lastMark = 0 + + /** The newest event, game and story ids seen, for `"latest"`. */ + private var lastEventId: String? = null + private var lastGameId: String? = null + private var lastPostedStory: String? = null + + // ---- Who ------------------------------------------------------------------ + + private fun userIdFor(to: String): Long { + to.toLongOrNull()?.let { return it } + handles[to]?.let { return it } + val found = + api.buddies().firstOrNull { it.handle == to || it.displayName == to }?.userId + ?: api.lookup(to)?.let { me -> me.userId } + ?: error("nobody called '$to' on this relay") + handles[to] = found + return found + } + + /** Claim an account address (fnid) with a fresh seed, as the app does at sign-in. */ + fun claim(): JSONObject { + state.optString("fnid").takeIf { it.isNotEmpty() }?.let { fnid -> + return JSONObject().put("fnid", fnid).put("address", address(fnid)) + } + val seed = ByteArray(32).also { SecureRandom().nextBytes(it) } + val fnid = AccountIdentity.claim(relay, seed, myUserId) + state.put("fnid", fnid) + state.put("accountSeed", java.util.Base64.getEncoder().encodeToString(seed)) + stateFile.writeText(state.toString(2)) + return JSONObject().put("fnid", fnid).put("address", address(fnid)) + } + + private fun address(fnid: String): String { + val host = java.net.URI(state.getString("relay")).host + return "$fnid@$host" + } + + // ---- Reading -------------------------------------------------------------- + + /** One opened envelope, as plainly as it can be written down. */ + fun describe(r: Conversations.Received): JSONObject { + val c = r.content + val o = + JSONObject() + .put("at", System.currentTimeMillis()) + .put("id", r.id) + .put("from_user", r.fromUser) + .put("from_device", r.fromDevice.take(8)) + .put("sealed", r.sealed) + .put("proven", r.senderProven) + .put("kind", c.kind) + .put("text", c.text) + c.messageId?.let { o.put("mid", it) } + c.groupId?.let { o.put("gid", it) } + c.raw?.let { o.put("raw", it) } + c.sticker?.let { o.put("sticker", JSONObject().put("pack", it.pack).put("id", it.id)) } + c.link?.let { o.put("link", it.toJson()) } + c.card?.let { card -> + o.put( + "card", + JSONObject(card.toJson()) + .put("sealedNow", card.isSealed(false, System.currentTimeMillis(), System.currentTimeMillis())), + ) + } + c.event?.let { + o.put("event", JSONObject(it.toJson())) + lastEventId = it.id + } + c.rsvp?.let { o.put("rsvp", JSONObject(it.toJson())) } + c.game?.let { g -> + lastGameId = g.id + o.put("game", JSONObject(g.toJson())) + o.put("gameAfter", gameView(g)) + } + c.unpost?.let { u -> + o.put("unpost", JSONObject().put("sid", u.storyId ?: JSONObject.NULL).put("set", u.setId ?: JSONObject.NULL)) + } + c.editOf?.let { o.put("editOf", it).put("editsStory", c.editsStory) } + c.attachment?.let { a -> + o.put( + "attachment", + JSONObject() + .put("kind", a.kind) + .put("id", a.id) + .put("mime", a.mime) + .put("w", a.width) + .put("h", a.height) + .put("bytes", a.bytes), + ) + } + c.storySet?.let { o.put("storySet", it.toJson()) } + o.put("controlKind", c.kind in setOf(Content.Kind.GAME_MOVE, Content.Kind.GAME_END, Content.Kind.RSVP, Content.Kind.UNPOST)) + return o + } + + /** Fold a game envelope from the other side into what this peer knows. */ + private fun gameView(g: Content.Game): JSONObject { + val theirs = g.mine + val mineSide = if (theirs == Content.Game.FIRST) Content.Game.SECOND else Content.Game.FIRST + val track = games.getOrPut(g.id) { Track(g.type, mineSide, emptyList()) } + val incoming = g.log + val note = + when { + incoming == null -> "start" + incoming.size > track.log.size && incoming.subList(0, track.log.size) == track.log -> { + track.log = incoming + "advanced" + } + incoming == track.log -> "same" + else -> "OUT OF STEP: known ${track.log} got $incoming" + } + if (g.ending != null) { + track.ending = g.ending + track.endedBy = theirs + } + return outcome(g.id).put("fold", note) + } + + /** The engine's verdict on what this peer holds for one game. */ + private fun outcome(gameId: String): JSONObject { + val track = games[gameId] ?: return JSONObject().put("error", "unknown game") + val type = GameType.fromWire(track.type) ?: return JSONObject().put("error", "unknown type") + val out = + JSONObject() + .put("game", gameId) + .put("t", track.type) + .put("peerPlays", track.mine) + .put("log", JSONArray(track.log)) + track.ending?.let { out.put("r", it).put("rBy", track.endedBy) } + when (val replay = Games.replay(type, track.log)) { + is ReplayResult.Replayed -> { + when (val result = Games.outcome(replay.position)) { + is Outcome.Ongoing -> out.put("outcome", "ongoing") + is Outcome.Win -> out.put("outcome", "win:${result.side.wire}:${result.reason.wire}") + is Outcome.Draw -> out.put("outcome", "draw:${result.reason.wire}") + } + out.put("legalNext", JSONArray(Games.legalMoves(replay.position))) + } + is ReplayResult.TooLong -> out.put("outcome", "too-long") + is ReplayResult.Failed -> out.put("outcome", "replay-failed-at-${replay.ply}:${replay.error.wire}") + } + return out + } + + // ---- Sending -------------------------------------------------------------- + + private fun send( + to: Long, + body: String, + ): JSONObject { + val sent = conversations.send(to, body) + return JSONObject() + .put("to", to) + .put("body", body) + .put("delivered", sent.delivered.size) + .put("failed", JSONObject(sent.failed as Map<*, *>)) + .put("sealed", sent.sealed) + } + + private fun mid() = UUID.randomUUID().toString() + + private fun now() = System.currentTimeMillis() / 1000 + + fun run(cmd: JSONObject): JSONObject { + val to = cmd.optString("to").takeIf { it.isNotEmpty() }?.let(::userIdFor) + fun target() = to ?: error("'${cmd.optString("cmd")}' needs \"to\"") + return when (val name = cmd.getString("cmd")) { + "add" -> { + api.addBuddy(cmd.getString("handle")) + JSONObject().put("added", cmd.getString("handle")) + } + + "claim" -> claim() + + "text" -> send(target(), Content.text(cmd.getString("body"), messageId = mid())) + + "rawtext" -> send(target(), cmd.getString("body")) + + "sticker" -> send(target(), Content.sticker(cmd.getString("pack"), cmd.getString("id"))) + + "card" -> { + val open = + when { + cmd.has("open") -> cmd.getLong("open") + cmd.has("openIn") -> now() + cmd.getLong("openIn") + else -> null + } + val card = + Content.Card( + design = cmd.optString("d", "hello"), + msg = cmd.optString("msg"), + to = cmd.optString("toLine").takeIf { it.isNotEmpty() }, + from = cmd.optString("from").takeIf { it.isNotEmpty() }, + open = open, + ) + send(target(), Content.card(card, mid())).put("open", open ?: JSONObject.NULL) + } + + "event" -> { + val id = cmd.optString("id").ifEmpty { UUID.randomUUID().toString() } + val before = events[id] + val event = + Content.Event( + id = id, + rev = cmd.optInt("rev", (before?.rev ?: 0) + 1), + title = cmd.optString("title").ifEmpty { before?.title ?: error("an event needs a title") }, + start = if (cmd.has("start")) cmd.getLong("start") else if (cmd.has("startIn")) now() + cmd.getLong("startIn") else before?.start, + end = if (cmd.has("end")) cmd.getLong("end") else if (cmd.has("endIn")) now() + cmd.getLong("endIn") else before?.end, + day = cmd.optString("day").takeIf { it.isNotEmpty() } ?: before?.day, + days = if (cmd.has("days")) cmd.getInt("days") else before?.days, + tz = cmd.optString("tz").takeIf { it.isNotEmpty() } ?: before?.tz, + place = cmd.optString("place").takeIf { it.isNotEmpty() } ?: before?.place, + note = cmd.optString("note").takeIf { it.isNotEmpty() } ?: before?.note, + cancelled = cmd.optBoolean("cancel", false), + ) + events[id] = event + send(target(), Content.event(event, mid())).put("eventId", id).put("rev", event.rev) + } + + "rsvp" -> { + val eid = cmd.getString("eid").let { if (it == "latest") lastEventId ?: error("no event yet") else it } + val n = maxOf(now(), (answered[eid] ?: 0L) + 1) + answered[eid] = n + send(target(), Content.rsvp(Content.Rsvp(eid, cmd.getString("r"), n))).put("n", n) + } + + "game" -> { + val id = UUID.randomUUID().toString() + val type = cmd.getString("t") + val mine = cmd.getString("mine") + games[id] = Track(type, mine, emptyList()) + lastGameId = id + send(target(), Content.game(Content.Game(id, type, mine), mid())).put("game", id) + } + + "gmove" -> { + val id = gameId(cmd) + val track = games[id] ?: error("no game $id here") + val next = track.log + cmd.getString("move") + val type = GameType.fromWire(track.type) ?: error("unknown type ${track.type}") + val replay = Games.replay(type, next) + require(replay is ReplayResult.Replayed) { "the engine refuses $next: $replay" } + require(type.sideOfPly(next.size - 1).wire == track.mine) { "not this peer's move: ${track.log}" } + val body = Content.gameMove(Content.Game(id, track.type, track.mine, next)) + val sent = send(target(), body) + track.log = next + sent.put("after", outcome(id)) + } + + "gend" -> { + val id = gameId(cmd) + val track = games[id] ?: error("no game $id here") + val body = Content.gameEnd(Content.Game(id, track.type, track.mine, track.log, cmd.getString("r"))) + val sent = send(target(), body) + track.ending = cmd.getString("r") + track.endedBy = track.mine + sent.put("after", outcome(id)) + } + + "gstate" -> { + outcome(gameId(cmd)) + } + + "games" -> { + val out = JSONObject() + games.keys.forEach { out.put(it, outcome(it)) } + out + } + + "story" -> { + val bytes = File(cmd.getString("path")).readBytes() + val posted = + conversations.postStory( + data = bytes, + mime = cmd.optString("mime", "image/jpeg"), + width = cmd.getInt("w"), + height = cmd.getInt("h"), + caption = cmd.optString("caption").takeIf { it.isNotEmpty() }, + toUserIds = listOf(target()), + ) + lastPostedStory = posted.ref.id + JSONObject() + .put("story", posted.ref.id) + .put("reached", posted.reached) + .put("missed", posted.missed) + } + + "storyedit" -> send(target(), Content.storyEdit(storyId(cmd), cmd.getString("caption"))) + + "unpost" -> send(target(), Content.unpostStory(storyId(cmd))) + + "mark" -> { + lastMark = received.size + JSONObject().put("seq", lastMark) + } + + "received" -> { + val from = cmd.optInt("after", 0) + JSONObject().put("messages", JSONArray(received.drop(from))) + } + + "dirshare" -> { + val url = cmd.getString("url") + val preview = Content.LinkPreview.of(url, cmd.getString("title"), "") + send(target(), Content.text(url, messageId = mid(), link = preview)) + } + + "whoami" -> { + JSONObject() + .put("handle", state.getString("username")) + .put("user_id", myUserId) + .put("fnid", state.optString("fnid")) + } + + else -> error("unknown command '$name'") + } + } + + private fun gameId(cmd: JSONObject): String = + cmd.optString("game").let { if (it.isEmpty() || it == "latest") lastGameId ?: error("no game yet") else it } + + private fun storyId(cmd: JSONObject): String = + cmd.optString("sid").let { if (it.isEmpty() || it == "latest") lastPostedStory ?: error("no story posted yet") else it } + + /** A value at a dotted path such as `event.rev`, as text, or null. */ + private fun at( + o: JSONObject, + path: String, + ): String? { + var node: Any? = o + for (part in path.split('.')) { + node = (node as? JSONObject)?.opt(part) ?: return null + } + return node?.toString() + } + + /** The first envelope from index [after] on that has [kind] and every value in [where]. */ + private fun match( + kind: String, + where: JSONObject?, + after: Int, + ): JSONObject? = + received.drop(after).firstOrNull { o -> + o.optString("kind") == kind && + (where == null || where.keys().asSequence().all { key -> at(o, key) == where.get(key).toString() }) + } + + private data class Pending( + val name: String, + val cmd: JSONObject, + val after: Int, + val deadline: Long, + ) + + // ---- The long-running correspondent --------------------------------------- + + fun bot( + commands: File, + seconds: Int, + ) { + commands.mkdirs() + val deadline = System.currentTimeMillis() + seconds * 1000L + log("START", JSONObject().put("user_id", myUserId).put("handle", state.getString("username"))) + var stop = false + val pending = mutableListOf() + while (!stop && System.currentTimeMillis() < deadline) { + val opened = + try { + conversations.poll() + } catch (e: Exception) { + log("POLL-ERROR", JSONObject().put("error", e.toString())) + emptyList() + } + for (r in opened) { + val o = runCatching { describe(r) }.getOrElse { JSONObject().put("describeError", it.toString()) } + o.put("seq", received.size) + received += o + log("RECV", o) + } + val settled = mutableListOf() + for (p in pending) { + val hit = match(p.cmd.getString("kind"), p.cmd.optJSONObject("where"), p.after) + val result = + when { + hit != null -> JSONObject().put("ok", true).put("message", hit) + System.currentTimeMillis() > p.deadline -> + JSONObject().put("ok", false).put("error", "nothing matched ${p.cmd} after #${p.after}") + else -> null + } ?: continue + settled += p + finish(commands, p.name, p.cmd, result) + } + pending.removeAll(settled) + val waiting = + commands + .listFiles { f -> f.name.endsWith(".cmd.json") } + .orEmpty() + .sortedBy { it.name } + for (file in waiting) { + val name = file.name.removeSuffix(".cmd.json") + val taken = File(commands, "$name.taken") + if (!file.renameTo(taken)) continue + val cmd = runCatching { JSONObject(taken.readText()) }.getOrNull() + if (cmd?.optString("cmd") == "expect") { + val after = if (cmd.has("after")) cmd.getInt("after") else lastMark + val seconds = cmd.optInt("timeout", 60) + pending += Pending(name, cmd, after, System.currentTimeMillis() + seconds * 1000L) + log("EXPECT $name", cmd) + continue + } + val result = + if (cmd == null) { + JSONObject().put("ok", false).put("error", "not JSON") + } else if (cmd.optString("cmd") == "stop") { + stop = true + JSONObject().put("ok", true) + } else { + try { + run(cmd).put("ok", true) + } catch (e: Exception) { + JSONObject().put("ok", false).put("error", e.toString()) + } + } + finish(commands, name, cmd, result) + } + Thread.sleep(1200) + } + log("STOP", JSONObject()) + } + + private fun finish( + commands: File, + name: String, + cmd: JSONObject?, + result: JSONObject, + ) { + log("DONE $name", JSONObject().put("cmd", cmd ?: JSONObject.NULL).put("result", result)) + val done = File(commands, "$name.done.json.part") + done.writeText(result.toString()) + done.renameTo(File(commands, "$name.done.json")) + } + + private fun log( + what: String, + o: JSONObject, + ) { + println("${java.time.Instant.now()} $what $o") + System.out.flush() + } +}