Removes the bare PUT; the one PUT now lives inside the room gate. v1 stays served unchecked through the same gate. Wire: chat_send_request.v2 (basis) and chat_send_receipt.v2 (newer); RoomMoved is v2 only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
10 KiB
JavaScript
182 lines
10 KiB
JavaScript
// gamecult.persona.chat_*.v1 and the v2 send pair: 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.
|
||
|
||
export const kinds = Object.freeze({
|
||
pendingRequest: "gamecult.persona.chat_pending_request.v1",
|
||
pending: "gamecult.persona.chat_pending.v1",
|
||
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"),
|
||
];
|
||
}
|
||
export function parseDelivery(t) {
|
||
const [s, roomAlias, roomId, eventId, sender, senderDisplay, body, atUnixMs, context, mentions, isDirect] = tuple(t, 11, "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"),
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|