7378d4c8ed
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>
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
// 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));
|
|
}
|