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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-04 17:40:01 -04:00
co-authored by Claude Opus 5.5
parent 067e6fcf51
commit 1d6de8ba72
3 changed files with 540 additions and 1 deletions
+2
View File
@@ -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")
}
@@ -44,7 +44,7 @@ object Peer {
@JvmStatic
fun main(args: Array<String>) {
if (args.size < 2) {
System.err.println("usage: peer <state.json> <create|add|send|poll|whoami> [...]")
System.err.println("usage: peer <state.json> <create|add|send|poll|whoami|claim|inspect|do|bot> [...]")
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 <command-folder> [seconds]` */
"bot" -> {
kinds.bot(File(args[2]), args.getOrNull(3)?.toIntOrNull() ?: 3600)
}
"whoami" -> {
println(
JSONObject()
@@ -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
* `<name>.cmd.json`, answering each with `<name>.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<String>,
var ending: String? = null,
var endedBy: String? = null,
)
private val games = HashMap<String, Track>()
/** Events this peer organised, so a revision can carry the whole event. */
private val events = HashMap<String, Content.Event>()
/** The last `n` sent for each event (DESIGN 7). */
private val answered = HashMap<String, Long>()
private val handles = HashMap<String, Long>()
/** Every envelope opened by `bot`, in order, as `describe` wrote it. */
private val received = mutableListOf<JSONObject>()
/** 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<Pending>()
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<Pending>()
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()
}
}