2026-11-03 11:01:44 +01:00
// gamecult.persona.chat_*.v1 and the v2 send pair: the documents Huginn and the Matrix mouth exchange.
2026-10-29 19:41:30 +01:00
// 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" ,
2026-11-03 11:01:44 +01:00
sendRequestV2 : "gamecult.persona.chat_send_request.v2" ,
sendReceiptV2 : "gamecult.persona.chat_send_receipt.v2" ,
2026-10-30 02:49:59 +01:00
refusal : "gamecult.persona.chat_refusal.v1" ,
2026-10-29 19:41:30 +01:00
} ) ;
2026-10-30 02:49:59 +01:00
export const refusals = Object . freeze ( [ "Brake" , "PersonaMismatch" , "UnknownRoom" , "EmptyText" , "SendFailed" , "UnknownPersona" ] ) ;
2026-10-29 19:41:30 +01:00
2026-11-03 11:01:44 +01:00
// 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" ] ) ;
2026-10-29 19:41:30 +01:00
const maxTextBytes = 4000 ;
2026-11-03 11:01:44 +01:00
export const maxNewer = 50 ;
2026-10-29 19:41:30 +01:00
const maxContext = 20 ;
2026-10-30 02:49:59 +01:00
// 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> $/ , "" ) ;
}
2026-10-29 19:41:30 +01:00
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" ) ;
2026-10-30 02:49:59 +01:00
if ( refusal !== null && ! refusals . includes ( refusal ) ) throw new Error ( ` unknown refusal ${ String ( refusal ) } ` ) ;
2026-10-29 19:41:30 +01:00
return { requestId : text ( requestId , "request_id" ) , eventId : eventId === null ? null : text ( eventId , "event_id" ) , refusal } ;
}
2026-11-03 11:01:44 +01:00
// 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 ) } ;
}
2026-10-30 02:49:59 +01:00
// 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 } ;
}
2026-10-29 19:41:30 +01:00
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" ) ] ) ,
2026-10-30 02:49:59 +01:00
d . mentions . map ( id => text ( id , "mention" ) ) , flag ( d . isDirect , "is_direct" ) ,
2026-10-29 19:41:30 +01:00
] ;
}
export function parseDelivery ( t ) {
2026-10-30 02:49:59 +01:00
const [ s , roomAlias , roomId , eventId , sender , senderDisplay , body , atUnixMs , context , mentions , isDirect ] = tuple ( t , 11 , "delivery" ) ;
2026-10-29 19:41:30 +01:00
if ( ! Array . isArray ( context ) ) throw new Error ( "delivery context must be a list" ) ;
2026-10-30 02:49:59 +01:00
if ( ! Array . isArray ( mentions ) ) throw new Error ( "delivery mentions must be a list" ) ;
2026-10-29 19:41:30 +01:00
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" ) } ; } ) ,
2026-10-30 02:49:59 +01:00
mentions : mentions . map ( id => text ( id , "mention" ) ) , isDirect : flag ( isDirect , "is_direct" ) ,
2026-10-29 19:41:30 +01:00
} ;
}
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 ;
}
2026-10-30 02:49:59 +01:00
function flag ( v , what ) {
if ( typeof v !== "boolean" ) throw new Error ( ` ${ what } must be a boolean ` ) ;
return v ;
}
2026-10-29 19:41:30 +01:00
function seq ( v , what ) {
if ( ! Number . isSafeInteger ( v ) || v < 0 ) throw new Error ( ` ${ what } must be a non-negative integer ` ) ;
return v ;
}