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) <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user