Phase 28 (Step 4): UI api/diplomail.ts wrappers
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m20s
Tests · Integration / integration (pull_request) Successful in 1m43s

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) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 22:35:21 +02:00
parent 4cb03736de
commit 7378d4c8ed
+421
View File
@@ -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<MailMessage[]> {
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<MailMessage[]> {
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<MailMessage> {
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<MailMessage> {
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<MailBroadcastReceipt> {
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<MailMessage | MailBroadcastReceipt> {
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<MailRecipientState> {
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<MailRecipientState> {
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<Uint8Array> {
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));
}