Bifrost/tools/persona-chat-documents.mjs
Heimdall 02275ee17b Matrix mouth: fetch and store chat images (cut A), ChatDelivery v3
m.image events in delivered messages are fetched as the receiving Persona,
classified by magic bytes, bounded by size and header pixel count, and stored
under IMAGE_STORE_ROOT/<persona>/<room hash>/<bytes hash>[.ext] (0640, dirs
2750). The delivery tuple gains index 11, nil or [path, mime, size, sha256];
the pending kind becomes gamecult.persona.chat_pending.v2 with Huginn's v3
golden bytes. Refusals keep the typed text line plus a fixed-word reason.
The mouth test homeserver is answered in-process so the suite runs under a
capped address space.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:18:55 +00:00

206 lines
12 KiB
JavaScript
Raw Blame History

// gamecult.persona.chat_*.v1, the v2 send pair and the v2 pending answer: the documents Huginn and the Matrix mouth exchange.
// Each kind is a positional tuple (the CultCache payload shape); this module owns
// the field order, the parsers and the envelope check. It does no I/O and loads no
// runtime, so the wire encoder (MessagePack) stays with the caller. The image path, mime and size bounds it checks
// come from persona-chat-images.mjs, which owns them.
import { IMAGE_PATH, MAX_BYTES, MIMES, extensionOf } from "./persona-chat-images.mjs";
export const kinds = Object.freeze({
pendingRequest: "gamecult.persona.chat_pending_request.v1",
pending: "gamecult.persona.chat_pending.v2",
ack: "gamecult.persona.chat_ack.v1",
sendRequest: "gamecult.persona.chat_send_request.v1",
sendReceipt: "gamecult.persona.chat_send_receipt.v1",
sendRequestV2: "gamecult.persona.chat_send_request.v2",
sendReceiptV2: "gamecult.persona.chat_send_receipt.v2",
refusal: "gamecult.persona.chat_refusal.v1",
});
export const refusals = Object.freeze(["Brake", "PersonaMismatch", "UnknownRoom", "EmptyText", "SendFailed", "UnknownPersona"]);
// RoomMoved exists only in the v2 receipt: the v1 tuple, a v1 parse and the typed refusal document do not know it.
export const refusalsV2 = Object.freeze([...refusals, "RoomMoved"]);
const maxTextBytes = 4000;
export const maxNewer = 50;
const maxContext = 20;
// Per-delivery bounds, applied where the mouth reads Matrix: the body is bounded like a send,
// a context line and a display name are smaller, and a reply is paged to a byte budget.
export const limits = Object.freeze({ textBytes: maxTextBytes, contextBytes: 1000, displayBytes: 256, mentions: 64, replyBytes: 256 * 1024 });
// `value` cut to at most `maxBytes` of UTF-8 without splitting a character.
export function clip(value, maxBytes) {
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
return Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8").replace(/<2F>$/, "");
}
export function pendingRequestTuple({ persona, afterSeq }) {
return [text(persona, "persona"), seq(afterSeq, "after_seq")];
}
export function parsePendingRequest(t) {
const [persona, afterSeq] = tuple(t, 2, "chat_pending_request");
return { persona: text(persona, "persona"), afterSeq: seq(afterSeq, "after_seq") };
}
export function ackTuple({ persona, throughSeq }) {
return [text(persona, "persona"), seq(throughSeq, "through_seq")];
}
export function parseAck(t) {
const [persona, throughSeq] = tuple(t, 2, "chat_ack");
return { persona: text(persona, "persona"), throughSeq: seq(throughSeq, "through_seq") };
}
export function sendRequestTuple({ requestId, persona, roomAlias, text: body }) {
return [text(requestId, "request_id"), text(persona, "persona"), text(roomAlias, "room_alias"), String(body)];
}
export function parseSendRequest(t) {
const [requestId, persona, roomAlias, body] = tuple(t, 4, "chat_send_request");
if (typeof body !== "string") throw new Error("chat_send_request text must be a string");
if (Buffer.byteLength(body, "utf8") > maxTextBytes) throw new Error("chat_send_request text is over the byte bound");
return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), roomAlias: text(roomAlias, "room_alias"), text: body };
}
export function sendReceiptTuple({ requestId, eventId = null, refusal = null }) {
if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal");
if (refusal !== null && !refusals.includes(refusal)) throw new Error(`unknown refusal ${refusal}`);
return [text(requestId, "request_id"), eventId, refusal];
}
export function parseSendReceipt(t) {
const [requestId, eventId, refusal] = tuple(t, 3, "chat_send_receipt");
if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal");
if (refusal !== null && !refusals.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`);
return { requestId: text(requestId, "request_id"), eventId: eventId === null ? null : text(eventId, "event_id"), refusal };
}
// v2: the same send plus a basis. basis is null (her choice stands: no check) or { seenEventId|null, sinceUnixMs }.
export function sendRequestV2Tuple({ requestId, persona, roomAlias, text: body, basis = null }) {
return [text(requestId, "request_id"), text(persona, "persona"), text(roomAlias, "room_alias"), String(body),
basis === null ? null : [basis.seenEventId === null ? null : text(basis.seenEventId, "seen_event_id"), seq(basis.sinceUnixMs, "since_unix_ms")]];
}
export function parseSendRequestV2(t) {
const [requestId, persona, roomAlias, body, basis] = tuple(t, 5, "chat_send_request_v2");
if (typeof body !== "string") throw new Error("chat_send_request text must be a string");
if (Buffer.byteLength(body, "utf8") > maxTextBytes) throw new Error("chat_send_request text is over the byte bound");
let parsed = null;
if (basis !== null) {
const [seen, since] = tuple(basis, 2, "basis");
parsed = { seenEventId: seen === null ? null : text(seen, "seen_event_id"), sinceUnixMs: seq(since, "since_unix_ms") };
}
return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), roomAlias: text(roomAlias, "room_alias"), text: body, basis: parsed };
}
// `newer` is the ids of the messages that moved the room, oldest first: non-empty exactly when the refusal is RoomMoved.
function checkReceiptV2(eventId, refusal, newer) {
if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal");
if (refusal !== null && !refusalsV2.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`);
if (!Array.isArray(newer) || newer.length > maxNewer || (newer.length > 0) !== (refusal === "RoomMoved")) throw new Error("newer is non-empty exactly when the refusal is RoomMoved, and at most 50 ids");
return newer.map(id => text(id, "newer"));
}
export function sendReceiptV2Tuple({ requestId, eventId = null, refusal = null, newer = [] }) {
return [text(requestId, "request_id"), eventId, refusal, checkReceiptV2(eventId, refusal, newer)];
}
export function parseSendReceiptV2(t) {
const [requestId, eventId, refusal, newer] = tuple(t, 4, "chat_send_receipt_v2");
return { requestId: text(requestId, "request_id"), eventId: eventId === null ? null : text(eventId, "event_id"), refusal, newer: checkReceiptV2(eventId, refusal, newer) };
}
// A typed refusal for a request that names a Persona the mouth does not serve.
export function refusalTuple({ persona, refusal }) {
if (!refusals.includes(refusal)) throw new Error(`unknown refusal ${refusal}`);
return [text(persona, "persona"), refusal];
}
export function parseRefusal(t) {
const [persona, refusal] = tuple(t, 2, "chat_refusal");
if (!refusals.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`);
return { persona: text(persona, "persona"), refusal };
}
export function deliveryTuple(d) {
return [
seq(d.seq, "seq"), text(d.roomAlias, "room_alias"), text(d.roomId, "room_id"), text(d.eventId, "event_id"),
text(d.sender, "sender"), text(d.senderDisplay, "sender_display"), String(d.text), seq(d.atUnixMs, "at_unix_ms"),
d.context.slice(-maxContext).map(c => [text(c.sender, "sender"), String(c.text), seq(c.atUnixMs, "at_unix_ms")]),
d.mentions.map(id => text(id, "mention")), flag(d.isDirect, "is_direct"), imageTuple(d.image ?? null),
];
}
// The image the mouth stored for a delivery, by reference: [path, mime, size, sha256] or null. The path is relative to the
// Persona's image store and is exactly `<hex sha256 of the room id>/<hex sha256 of the bytes>[.<ext of the mime>]`, so it can
// be neither absolute nor climb out of the store. Whether the file matches the rest is the reader's to re-check.
function imageTuple(image) {
if (image === null) return null;
const { path, mime, size, sha256 } = image;
if (typeof path !== "string" || !IMAGE_PATH.test(path)) throw new Error("image path must be <room hash>/<bytes hash>[.ext]");
if (!MIMES.includes(mime)) throw new Error("image mime must be image/png, image/jpeg, image/gif or image/webp");
if (path.includes(".") && !path.endsWith(`.${extensionOf(mime)}`)) throw new Error("image path extension must match its mime");
if (!Number.isSafeInteger(size) || size < 1 || size > MAX_BYTES) throw new Error("image size must be 1 to 5 MiB");
if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)) throw new Error("image sha256 must be lowercase hex");
if (!path.split("/")[1].startsWith(sha256)) throw new Error("image path must name its sha256");
return [path, mime, size, sha256];
}
function parseImage(t) {
const [path, mime, size, sha256] = tuple(t, 4, "image");
imageTuple({ path, mime, size, sha256 });
return { path, mime, size, sha256 };
}
export function parseDelivery(t) {
const [s, roomAlias, roomId, eventId, sender, senderDisplay, body, atUnixMs, context, mentions, isDirect, image] = tuple(t, 12, "delivery");
if (!Array.isArray(context)) throw new Error("delivery context must be a list");
if (!Array.isArray(mentions)) throw new Error("delivery mentions must be a list");
return {
seq: seq(s, "seq"), roomAlias: text(roomAlias, "room_alias"), roomId: text(roomId, "room_id"), eventId: text(eventId, "event_id"),
sender: text(sender, "sender"), senderDisplay: text(senderDisplay, "sender_display"), text: String(body), atUnixMs: seq(atUnixMs, "at_unix_ms"),
context: context.map(c => { const [cs, ct, ca] = tuple(c, 3, "context"); return { sender: text(cs, "sender"), text: String(ct), atUnixMs: seq(ca, "at_unix_ms") }; }),
mentions: mentions.map(id => text(id, "mention")), isDirect: flag(isDirect, "is_direct"),
image: image === null ? null : parseImage(image),
};
}
export function pendingTuple({ persona, deliveries }) {
return [text(persona, "persona"), deliveries.map(deliveryTuple)];
}
export function parsePending(t) {
const [persona, deliveries] = tuple(t, 2, "chat_pending");
if (!Array.isArray(deliveries)) throw new Error("chat_pending deliveries must be a list");
return { persona: text(persona, "persona"), deliveries: deliveries.map(parseDelivery) };
}
// The CultNet document_put_raw envelope every chat_* document travels in.
export function envelope({ kind, recordKey, payload, sourceRuntimeId, sourceAgentId, sourceRole }) {
return {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: `${kind}:${recordKey}`,
document: {
schemaId: kind, recordKey, storedAt: new Date().toISOString(), payloadEncoding: "messagepack", payload,
sourceRuntimeId, sourceAgentId, sourceRole, tags: ["cultnet.transport.rudp.v0"],
},
};
}
export function checkEnvelope(message, allowedKinds) {
const d = message?.document;
if (message?.schemaVersion !== "cultnet.document_put_raw.v0" || !d) throw new Error("not a document_put_raw message");
if (!allowedKinds.includes(d.schemaId)) throw new Error(`document kind ${String(d.schemaId)} is not accepted here`);
if (d.payloadEncoding !== "messagepack") throw new Error("chat documents are MessagePack");
if (!Array.isArray(d.tags) || d.tags.length !== 1 || d.tags[0] !== "cultnet.transport.rudp.v0") throw new Error("chat document tags substituted authority");
return d;
}
function tuple(t, n, what) {
if (!Array.isArray(t) || t.length !== n) throw new Error(`${what} must be a ${n}-field tuple`);
return t;
}
function text(v, what) {
if (typeof v !== "string" || v === "") throw new Error(`${what} must be a non-empty string`);
return v;
}
function flag(v, what) {
if (typeof v !== "boolean") throw new Error(`${what} must be a boolean`);
return v;
}
function seq(v, what) {
if (!Number.isSafeInteger(v) || v < 0) throw new Error(`${what} must be a non-negative integer`);
return v;
}