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>
206 lines
12 KiB
JavaScript
206 lines
12 KiB
JavaScript
// 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;
|
||
}
|