From 7378d4c8ed9b6accf7178a6f19fceee31f047461 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 22:35:21 +0200 Subject: [PATCH] Phase 28 (Step 4): UI api/diplomail.ts wrappers Adds typed wrappers around `GalaxyClient.executeCommand` for the eight Phase 28 mail RPCs. Each wrapper builds the matching FlatBuffers request, decodes the response, and surfaces backend errors through a dedicated `MailError` (mirroring `LobbyError`). The compose helpers accept the recipient race name directly so the UI can feed it straight from `report.races[].name` without a membership lookup. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/api/diplomail.ts | 421 +++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 ui/frontend/src/api/diplomail.ts diff --git a/ui/frontend/src/api/diplomail.ts b/ui/frontend/src/api/diplomail.ts new file mode 100644 index 0000000..8177f24 --- /dev/null +++ b/ui/frontend/src/api/diplomail.ts @@ -0,0 +1,421 @@ +// 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)); +}