// Typed wrappers around `GalaxyClient.executeCommand` for the eight // `user.games.mail.*` Phase 28 ConnectRPC commands. Each wrapper // builds the matching FlatBuffers request, decodes the FlatBuffers // response, and surfaces backend errors through `MailError` so callers // branch on canonical codes (`invalid_request`, `forbidden`, // `not_found`, `conflict`). import { Builder, ByteBuffer } from "flatbuffers"; import type { GalaxyClient } from "./galaxy-client"; import { uuidToHiLo } from "./game-state"; import { AdminRequest, AdminResponse, BroadcastRequest, BroadcastResponse, DeleteRequest, DeleteResponse, InboxRequest, InboxResponse, MailMessage as FbsMailMessage, MailRecipientState as FbsMailRecipientState, MailBroadcastReceipt as FbsMailBroadcastReceipt, MessageGetRequest, MessageGetResponse, ReadRequest, ReadResponse, SendRequest, SendResponse, SentRequest, SentResponse, } from "../proto/galaxy/fbs/diplomail"; import { UUID } from "../proto/galaxy/fbs/common"; import { ErrorResponse as FbsErrorResponse } from "../proto/galaxy/fbs/lobby"; /** * MailError represents a non-`ok` response from a mail RPC. Callers * branch on `code` for canonical error handling and use `message` for * inline UI surfacing. */ export class MailError extends Error { readonly resultCode: string; readonly code: string; constructor(resultCode: string, code: string, message: string) { super(message); this.name = "MailError"; this.resultCode = resultCode; this.code = code; } } /** * MailMessage is the typed UI view of a `MailMessage` FlatBuffers row. * Nullable wire fields (`sender_user_id`, timestamps, translation * slots) become `null` here; the empty string from FB readers is * normalised to either `""` or `null` based on field semantics. */ export interface MailMessage { messageId: string; gameId: string; gameName: string; kind: string; senderKind: string; senderUserId: string | null; senderUsername: string | null; senderRaceName: string | null; subject: string; body: string; bodyLang: string; broadcastScope: string; createdAt: Date; recipientUserId: string; recipientUserName: string; recipientRaceName: string | null; readAt: Date | null; deletedAt: Date | null; translatedSubject: string | null; translatedBody: string | null; translationLang: string | null; translator: string | null; } export interface MailRecipientState { messageId: string; readAt: Date | null; deletedAt: Date | null; } export interface MailBroadcastReceipt { messageId: string; gameId: string; gameName: string; kind: string; senderKind: string; subject: string; body: string; bodyLang: string; broadcastScope: string; createdAt: Date; recipientCount: number; } export interface SendPersonalArgs { gameId: string; raceName: string; subject?: string; body: string; } export interface SendBroadcastArgs { gameId: string; subject?: string; body: string; } export type AdminTarget = "user" | "all"; export interface SendAdminArgs { gameId: string; target: AdminTarget; raceName?: string; recipientUserId?: string; recipients?: string; subject?: string; body: string; } const MESSAGE_TYPE_INBOX = "user.games.mail.inbox"; const MESSAGE_TYPE_SENT = "user.games.mail.sent"; const MESSAGE_TYPE_GET = "user.games.mail.message.get"; const MESSAGE_TYPE_SEND = "user.games.mail.send"; const MESSAGE_TYPE_BROADCAST = "user.games.mail.broadcast"; const MESSAGE_TYPE_ADMIN = "user.games.mail.admin"; const MESSAGE_TYPE_READ = "user.games.mail.read"; const MESSAGE_TYPE_DELETE = "user.games.mail.delete"; const RESULT_CODE_OK = "ok"; export async function fetchInbox( client: GalaxyClient, gameId: string, ): Promise { const builder = new Builder(64); const [hi, lo] = uuidToHiLo(gameId); InboxRequest.startInboxRequest(builder); InboxRequest.addGameId(builder, UUID.createUUID(builder, hi, lo)); builder.finish(InboxRequest.endInboxRequest(builder)); const payload = await execute(client, MESSAGE_TYPE_INBOX, builder.asUint8Array()); const response = InboxResponse.getRootAsInboxResponse(new ByteBuffer(payload)); return readMessageList(response.itemsLength.bind(response), (i) => response.items(i)); } export async function fetchSent( client: GalaxyClient, gameId: string, ): Promise { const builder = new Builder(64); const [hi, lo] = uuidToHiLo(gameId); SentRequest.startSentRequest(builder); SentRequest.addGameId(builder, UUID.createUUID(builder, hi, lo)); builder.finish(SentRequest.endSentRequest(builder)); const payload = await execute(client, MESSAGE_TYPE_SENT, builder.asUint8Array()); const response = SentResponse.getRootAsSentResponse(new ByteBuffer(payload)); return readMessageList(response.itemsLength.bind(response), (i) => response.items(i)); } export async function fetchMessage( client: GalaxyClient, gameId: string, messageId: string, ): Promise { const builder = new Builder(64); const [ghi, glo] = uuidToHiLo(gameId); const [mhi, mlo] = uuidToHiLo(messageId); MessageGetRequest.startMessageGetRequest(builder); MessageGetRequest.addGameId(builder, UUID.createUUID(builder, ghi, glo)); MessageGetRequest.addMessageId(builder, UUID.createUUID(builder, mhi, mlo)); builder.finish(MessageGetRequest.endMessageGetRequest(builder)); const payload = await execute(client, MESSAGE_TYPE_GET, builder.asUint8Array()); const response = MessageGetResponse.getRootAsMessageGetResponse(new ByteBuffer(payload)); const fb = response.message(); if (fb === null) { throw new MailError("internal_error", "internal_error", "message missing in response"); } return decodeMailMessage(fb); } export async function sendPersonal( client: GalaxyClient, input: SendPersonalArgs, ): Promise { const builder = new Builder(256); const [hi, lo] = uuidToHiLo(input.gameId); const raceOff = builder.createString(input.raceName); const subjectOff = builder.createString(input.subject ?? ""); const bodyOff = builder.createString(input.body); SendRequest.startSendRequest(builder); SendRequest.addGameId(builder, UUID.createUUID(builder, hi, lo)); SendRequest.addRecipientRaceName(builder, raceOff); SendRequest.addSubject(builder, subjectOff); SendRequest.addBody(builder, bodyOff); builder.finish(SendRequest.endSendRequest(builder)); const payload = await execute(client, MESSAGE_TYPE_SEND, builder.asUint8Array()); const response = SendResponse.getRootAsSendResponse(new ByteBuffer(payload)); const fb = response.message(); if (fb === null) { throw new MailError("internal_error", "internal_error", "message missing in response"); } return decodeMailMessage(fb); } export async function sendBroadcast( client: GalaxyClient, input: SendBroadcastArgs, ): Promise { const builder = new Builder(256); const [hi, lo] = uuidToHiLo(input.gameId); const subjectOff = builder.createString(input.subject ?? ""); const bodyOff = builder.createString(input.body); BroadcastRequest.startBroadcastRequest(builder); BroadcastRequest.addGameId(builder, UUID.createUUID(builder, hi, lo)); BroadcastRequest.addSubject(builder, subjectOff); BroadcastRequest.addBody(builder, bodyOff); builder.finish(BroadcastRequest.endBroadcastRequest(builder)); const payload = await execute(client, MESSAGE_TYPE_BROADCAST, builder.asUint8Array()); const response = BroadcastResponse.getRootAsBroadcastResponse(new ByteBuffer(payload)); const fb = response.receipt(); if (fb === null) { throw new MailError("internal_error", "internal_error", "receipt missing in response"); } return decodeMailBroadcastReceipt(fb); } export async function sendAdmin( client: GalaxyClient, input: SendAdminArgs, ): Promise { const builder = new Builder(256); const [hi, lo] = uuidToHiLo(input.gameId); const targetOff = builder.createString(input.target); const recipientUserOff = builder.createString(input.recipientUserId ?? ""); const recipientRaceOff = builder.createString(input.raceName ?? ""); const recipientsOff = builder.createString(input.recipients ?? ""); const subjectOff = builder.createString(input.subject ?? ""); const bodyOff = builder.createString(input.body); AdminRequest.startAdminRequest(builder); AdminRequest.addGameId(builder, UUID.createUUID(builder, hi, lo)); AdminRequest.addTarget(builder, targetOff); AdminRequest.addRecipientUserId(builder, recipientUserOff); AdminRequest.addRecipientRaceName(builder, recipientRaceOff); AdminRequest.addRecipients(builder, recipientsOff); AdminRequest.addSubject(builder, subjectOff); AdminRequest.addBody(builder, bodyOff); builder.finish(AdminRequest.endAdminRequest(builder)); const payload = await execute(client, MESSAGE_TYPE_ADMIN, builder.asUint8Array()); const response = AdminResponse.getRootAsAdminResponse(new ByteBuffer(payload)); const receipt = response.receipt(); if (receipt !== null) { return decodeMailBroadcastReceipt(receipt); } const message = response.message(); if (message !== null) { return decodeMailMessage(message); } throw new MailError("internal_error", "internal_error", "admin response carried neither message nor receipt"); } export async function markRead( client: GalaxyClient, gameId: string, messageId: string, ): Promise { const builder = new Builder(64); const [ghi, glo] = uuidToHiLo(gameId); const [mhi, mlo] = uuidToHiLo(messageId); ReadRequest.startReadRequest(builder); ReadRequest.addGameId(builder, UUID.createUUID(builder, ghi, glo)); ReadRequest.addMessageId(builder, UUID.createUUID(builder, mhi, mlo)); builder.finish(ReadRequest.endReadRequest(builder)); const payload = await execute(client, MESSAGE_TYPE_READ, builder.asUint8Array()); const response = ReadResponse.getRootAsReadResponse(new ByteBuffer(payload)); const fb = response.state(); if (fb === null) { throw new MailError("internal_error", "internal_error", "state missing in response"); } return decodeMailRecipientState(fb); } export async function deleteMessage( client: GalaxyClient, gameId: string, messageId: string, ): Promise { const builder = new Builder(64); const [ghi, glo] = uuidToHiLo(gameId); const [mhi, mlo] = uuidToHiLo(messageId); DeleteRequest.startDeleteRequest(builder); DeleteRequest.addGameId(builder, UUID.createUUID(builder, ghi, glo)); DeleteRequest.addMessageId(builder, UUID.createUUID(builder, mhi, mlo)); builder.finish(DeleteRequest.endDeleteRequest(builder)); const payload = await execute(client, MESSAGE_TYPE_DELETE, builder.asUint8Array()); const response = DeleteResponse.getRootAsDeleteResponse(new ByteBuffer(payload)); const fb = response.state(); if (fb === null) { throw new MailError("internal_error", "internal_error", "state missing in response"); } return decodeMailRecipientState(fb); } async function execute( client: GalaxyClient, messageType: string, payloadBytes: Uint8Array, ): Promise { const result = await client.executeCommand(messageType, payloadBytes); if (result.resultCode !== RESULT_CODE_OK) { throw decodeMailError(result.resultCode, result.payloadBytes); } return result.payloadBytes; } function decodeMailError(resultCode: string, payload: Uint8Array): MailError { let code = resultCode; let message = resultCode; try { const errorResponse = FbsErrorResponse.getRootAsErrorResponse(new ByteBuffer(payload)); const body = errorResponse.error(); if (body) { code = body.code() ?? resultCode; message = body.message() ?? resultCode; } } catch (_err) { // fall through to use raw resultCode } return new MailError(resultCode, code, message); } function readMessageList( lengthFn: () => number, getFn: (i: number) => FbsMailMessage | null, ): MailMessage[] { const total = lengthFn(); const out: MailMessage[] = []; for (let i = 0; i < total; i++) { const item = getFn(i); if (item) { out.push(decodeMailMessage(item)); } } return out; } function decodeMailMessage(fb: FbsMailMessage): MailMessage { return { messageId: fb.messageId() ?? "", gameId: fb.gameId() ?? "", gameName: fb.gameName() ?? "", kind: fb.kind() ?? "", senderKind: fb.senderKind() ?? "", senderUserId: optionalString(fb.senderUserId()), senderUsername: optionalString(fb.senderUsername()), senderRaceName: optionalString(fb.senderRaceName()), subject: fb.subject() ?? "", body: fb.body() ?? "", bodyLang: fb.bodyLang() ?? "", broadcastScope: fb.broadcastScope() ?? "", createdAt: dateFromMs(fb.createdAtMs()), recipientUserId: fb.recipientUserId() ?? "", recipientUserName: fb.recipientUserName() ?? "", recipientRaceName: optionalString(fb.recipientRaceName()), readAt: optionalDateFromMs(fb.readAtMs()), deletedAt: optionalDateFromMs(fb.deletedAtMs()), translatedSubject: optionalString(fb.translatedSubject()), translatedBody: optionalString(fb.translatedBody()), translationLang: optionalString(fb.translationLang()), translator: optionalString(fb.translator()), }; } function decodeMailRecipientState(fb: FbsMailRecipientState): MailRecipientState { return { messageId: fb.messageId() ?? "", readAt: optionalDateFromMs(fb.readAtMs()), deletedAt: optionalDateFromMs(fb.deletedAtMs()), }; } function decodeMailBroadcastReceipt(fb: FbsMailBroadcastReceipt): MailBroadcastReceipt { return { messageId: fb.messageId() ?? "", gameId: fb.gameId() ?? "", gameName: fb.gameName() ?? "", kind: fb.kind() ?? "", senderKind: fb.senderKind() ?? "", subject: fb.subject() ?? "", body: fb.body() ?? "", bodyLang: fb.bodyLang() ?? "", broadcastScope: fb.broadcastScope() ?? "", createdAt: dateFromMs(fb.createdAtMs()), recipientCount: fb.recipientCount(), }; } function optionalString(value: string | null | undefined): string | null { if (value === null || value === undefined || value === "") { return null; } return value; } function dateFromMs(ms: bigint): Date { return new Date(Number(ms)); } function optionalDateFromMs(ms: bigint): Date | null { if (ms === 0n) { return null; } return new Date(Number(ms)); }