Files
flamenet-messenger-android/interop/src/main/kotlin/io/flamenet/interop/PeerKinds.kt
T
Paul HittandClaude Opus 5.5 1d6de8ba72 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>
2026-10-04 17:40:01 -04:00

515 lines
21 KiB
Kotlin

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()
}
}