Files
scrabble-game/ui/src/lib/codec.ts
T
Ilia Denisov 3590df28db
Tests · Go / test (push) Successful in 7s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Failing after 5m9s
Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
New platform/telegram connector (own container, bot token only there):
- go-telegram/bot long-poll loop: /start deep-links + Mini App launch button.
- gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify
  (renders a localized message + deep-link button), SendToUser/SendToGameChannel
  (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id).
- Bot API base override for Telegram's test environment; Dockerfile + compose
  (VPN sidecar, no public ingress); README.

Gateway:
- initData validation relocated from the gateway into the connector; the gateway
  calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token,
  and deletes internal/auth.
- Out-of-app push: runPushPump routes events whose recipient has no live in-app
  stream to connector.Notify, gated by /internal/push-target + the in-app-only
  flag (race-free de-dup); HasSubscribers added to the push hub.

Backend:
- Migration 00007 accounts.notifications_in_app_only (default true) + jetgen.
- ProvisionTelegram seeds a new account's language/display name from the launch
  fields; IdentityExternalID reverse lookup; /internal/push-target handler.

UI:
- Telegram Mini App launch: detect initData, apply themeParams, authTelegram,
  route the deep-link start_param (g/i/f); /telegram/ guard redirects outside
  Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle;
  share-to-Telegram link for a friend code. Vitest + Playwright coverage.

Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only
(Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN,
ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
2026-06-04 01:48:03 +02:00

602 lines
20 KiB
TypeScript

// FlatBuffers <-> domain-model codec. The real transport encodes each request table
// and decodes each response/event table here, mirroring the gateway's Go encoders in
// reverse. The screens only ever see the plain model (lib/model), never these wire
// types.
import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
import * as fb from '../gen/fbs/scrabblefb';
import type { PlacedTile } from './client';
import type {
AccountRef,
ChatMessage,
EvalResult,
FriendCode,
GameList,
GameView,
GcgExport,
History,
HintResult,
Invitation,
InvitationInvitee,
InvitationSettings,
MatchResult,
MoveRecord,
MoveResult,
Profile,
ProfileUpdate,
PushEvent,
Seat,
Session,
StateView,
Stats,
Tile,
Variant,
WordCheckResult,
} from './model';
// --- request encoders ---
function buildTile(b: Builder, t: PlacedTile): Offset {
const letter = b.createString(t.letter);
fb.TileRecord.startTileRecord(b);
fb.TileRecord.addRow(b, t.row);
fb.TileRecord.addCol(b, t.col);
fb.TileRecord.addLetter(b, letter);
fb.TileRecord.addBlank(b, t.blank);
return fb.TileRecord.endTileRecord(b);
}
function finish(b: Builder, root: Offset): Uint8Array {
b.finish(root);
return b.asUint8Array();
}
export const empty = (): Uint8Array => new Uint8Array();
export function encodeGameAction(gameId: string): Uint8Array {
const b = new Builder(64);
const gid = b.createString(gameId);
fb.GameActionRequest.startGameActionRequest(b);
fb.GameActionRequest.addGameId(b, gid);
return finish(b, fb.GameActionRequest.endGameActionRequest(b));
}
export function encodeStateRequest(gameId: string): Uint8Array {
const b = new Builder(64);
const gid = b.createString(gameId);
fb.StateRequest.startStateRequest(b);
fb.StateRequest.addGameId(b, gid);
return finish(b, fb.StateRequest.endStateRequest(b));
}
export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildTile(b, t));
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
const d = b.createString(dir);
fb.SubmitPlayRequest.startSubmitPlayRequest(b);
fb.SubmitPlayRequest.addGameId(b, gid);
fb.SubmitPlayRequest.addDir(b, d);
fb.SubmitPlayRequest.addTiles(b, vec);
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
}
export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildTile(b, t));
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
const d = b.createString(dir);
fb.EvalRequest.startEvalRequest(b);
fb.EvalRequest.addGameId(b, gid);
fb.EvalRequest.addDir(b, d);
fb.EvalRequest.addTiles(b, vec);
return finish(b, fb.EvalRequest.endEvalRequest(b));
}
export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
const b = new Builder(128);
const offs = tiles.map((s) => b.createString(s));
const vec = fb.ExchangeRequest.createTilesVector(b, offs);
const gid = b.createString(gameId);
fb.ExchangeRequest.startExchangeRequest(b);
fb.ExchangeRequest.addGameId(b, gid);
fb.ExchangeRequest.addTiles(b, vec);
return finish(b, fb.ExchangeRequest.endExchangeRequest(b));
}
export function encodeCheckWord(gameId: string, word: string): Uint8Array {
const b = new Builder(128);
const gid = b.createString(gameId);
const w = b.createString(word);
fb.CheckWordRequest.startCheckWordRequest(b);
fb.CheckWordRequest.addGameId(b, gid);
fb.CheckWordRequest.addWord(b, w);
return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
}
export function encodeComplaint(gameId: string, word: string, note: string): Uint8Array {
const b = new Builder(256);
const gid = b.createString(gameId);
const w = b.createString(word);
const n = b.createString(note);
fb.ComplaintRequest.startComplaintRequest(b);
fb.ComplaintRequest.addGameId(b, gid);
fb.ComplaintRequest.addWord(b, w);
fb.ComplaintRequest.addNote(b, n);
return finish(b, fb.ComplaintRequest.endComplaintRequest(b));
}
export function encodeEnqueue(variant: Variant): Uint8Array {
const b = new Builder(64);
const v = b.createString(variant);
fb.EnqueueRequest.startEnqueueRequest(b);
fb.EnqueueRequest.addVariant(b, v);
return finish(b, fb.EnqueueRequest.endEnqueueRequest(b));
}
export function encodeChatPost(gameId: string, body: string): Uint8Array {
const b = new Builder(128);
const gid = b.createString(gameId);
const bd = b.createString(body);
fb.ChatPostRequest.startChatPostRequest(b);
fb.ChatPostRequest.addGameId(b, gid);
fb.ChatPostRequest.addBody(b, bd);
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
}
export function encodeTelegramLogin(initData: string): Uint8Array {
const b = new Builder(512);
const d = b.createString(initData);
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
fb.TelegramLoginRequest.addInitData(b, d);
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
}
export function encodeGuestLogin(locale: string): Uint8Array {
const b = new Builder(64);
const l = b.createString(locale);
fb.GuestLoginRequest.startGuestLoginRequest(b);
fb.GuestLoginRequest.addLocale(b, l);
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}
export function encodeEmailRequest(email: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
fb.EmailRequestRequest.startEmailRequestRequest(b);
fb.EmailRequestRequest.addEmail(b, e);
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}
export function encodeEmailLogin(email: string, code: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const c = b.createString(code);
fb.EmailLoginRequest.startEmailLoginRequest(b);
fb.EmailLoginRequest.addEmail(b, e);
fb.EmailLoginRequest.addCode(b, c);
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
}
// --- response decoders ---
function s(v: string | null): string {
return v ?? '';
}
function decodeTile(t: fb.TileRecord): Tile {
return { row: t.row(), col: t.col(), letter: s(t.letter()), blank: t.blank() };
}
function decodeSeat(v: fb.SeatView): Seat {
return {
seat: v.seat(),
accountId: s(v.accountId()),
displayName: s(v.displayName()),
score: v.score(),
hintsUsed: v.hintsUsed(),
isWinner: v.isWinner(),
};
}
function decodeGameView(g: fb.GameView): GameView {
const seats: Seat[] = [];
for (let i = 0; i < g.seatsLength(); i++) {
const sv = g.seats(i);
if (sv) seats.push(decodeSeat(sv));
}
return {
id: s(g.id()),
variant: s(g.variant()) as Variant,
dictVersion: s(g.dictVersion()),
status: s(g.status()),
players: g.players(),
toMove: g.toMove(),
turnTimeoutSecs: g.turnTimeoutSecs(),
moveCount: g.moveCount(),
endReason: s(g.endReason()),
seats,
};
}
function decodeMove(m: fb.MoveRecord): MoveRecord {
const tiles: Tile[] = [];
for (let i = 0; i < m.tilesLength(); i++) {
const t = m.tiles(i);
if (t) tiles.push(decodeTile(t));
}
const words: string[] = [];
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)));
return {
player: m.player(),
action: s(m.action()),
dir: s(m.dir()),
mainRow: m.mainRow(),
mainCol: m.mainCol(),
tiles,
words,
count: m.count(),
score: m.score(),
total: m.total(),
};
}
function decodeChatMsg(m: fb.ChatMessage): ChatMessage {
return {
id: s(m.id()),
gameId: s(m.gameId()),
senderId: s(m.senderId()),
kind: s(m.kind()),
body: s(m.body()),
createdAtUnix: Number(m.createdAtUnix()),
};
}
export function decodeSession(buf: Uint8Array): Session {
const t = fb.Session.getRootAsSession(new ByteBuffer(buf));
return { token: s(t.token()), userId: s(t.userId()), isGuest: t.isGuest(), displayName: s(t.displayName()) };
}
export function decodeProfile(buf: Uint8Array): Profile {
const p = fb.Profile.getRootAsProfile(new ByteBuffer(buf));
return {
userId: s(p.userId()),
displayName: s(p.displayName()),
preferredLanguage: s(p.preferredLanguage()),
timeZone: s(p.timeZone()),
awayStart: s(p.awayStart()),
awayEnd: s(p.awayEnd()),
hintBalance: p.hintBalance(),
blockChat: p.blockChat(),
blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(),
notificationsInAppOnly: p.notificationsInAppOnly(),
};
}
export function decodeStateView(buf: Uint8Array): StateView {
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
const g = v.game();
const rack: string[] = [];
for (let i = 0; i < v.rackLength(); i++) rack.push(s(v.rack(i)));
return {
game: g ? decodeGameView(g) : emptyGame(),
seat: v.seat(),
rack,
bagLen: v.bagLen(),
hintsRemaining: v.hintsRemaining(),
};
}
export function decodeMoveResult(buf: Uint8Array): MoveResult {
const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf));
const m = r.move();
const g = r.game();
return { move: m ? decodeMove(m) : emptyMove(), game: g ? decodeGameView(g) : emptyGame() };
}
export function decodeHintResult(buf: Uint8Array): HintResult {
const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
const m = r.move();
return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining() };
}
export function decodeEvalResult(buf: Uint8Array): EvalResult {
const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf));
const words: string[] = [];
for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i)));
return { legal: r.legal(), score: r.score(), words };
}
export function decodeWordCheck(buf: Uint8Array): WordCheckResult {
const r = fb.WordCheckResult.getRootAsWordCheckResult(new ByteBuffer(buf));
return { word: s(r.word()), legal: r.legal() };
}
export function decodeHistory(buf: Uint8Array): History {
const h = fb.History.getRootAsHistory(new ByteBuffer(buf));
const moves: MoveRecord[] = [];
for (let i = 0; i < h.movesLength(); i++) {
const m = h.moves(i);
if (m) moves.push(decodeMove(m));
}
return { gameId: s(h.gameId()), moves };
}
export function decodeGameList(buf: Uint8Array): GameList {
const gl = fb.GameList.getRootAsGameList(new ByteBuffer(buf));
const games: GameView[] = [];
for (let i = 0; i < gl.gamesLength(); i++) {
const g = gl.games(i);
if (g) games.push(decodeGameView(g));
}
return { games };
}
export function decodeMatchResult(buf: Uint8Array): MatchResult {
const m = fb.MatchResult.getRootAsMatchResult(new ByteBuffer(buf));
const g = m.game();
return { matched: m.matched(), game: m.matched() && g ? decodeGameView(g) : undefined };
}
export function decodeChatMessage(buf: Uint8Array): ChatMessage {
return decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(new ByteBuffer(buf)));
}
export function decodeChatList(buf: Uint8Array): ChatMessage[] {
const cl = fb.ChatList.getRootAsChatList(new ByteBuffer(buf));
const out: ChatMessage[] = [];
for (let i = 0; i < cl.messagesLength(); i++) {
const m = cl.messages(i);
if (m) out.push(decodeChatMsg(m));
}
return out;
}
export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null {
const bb = new ByteBuffer(payload);
switch (kind) {
case 'your_turn': {
const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb);
return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()) };
}
case 'opponent_moved': {
const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb);
return { kind: 'opponent_moved', gameId: s(e.gameId()), seat: e.seat(), action: s(e.action()), score: e.score(), total: e.total() };
}
case 'chat_message':
return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
case 'nudge': {
const e = fb.NudgeEvent.getRootAsNudgeEvent(bb);
return { kind: 'nudge', gameId: s(e.gameId()), fromUserId: s(e.fromUserId()) };
}
case 'match_found': {
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
return { kind: 'match_found', gameId: s(e.gameId()) };
}
case 'notify': {
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
return { kind: 'notify', sub: s(e.kind()) };
}
case 'heartbeat':
return { kind: 'heartbeat' };
default:
return null;
}
}
// --- Stage 8 encoders ---
export function encodeTarget(accountId: string): Uint8Array {
const b = new Builder(64);
const id = b.createString(accountId);
fb.TargetRequest.startTargetRequest(b);
fb.TargetRequest.addAccountId(b, id);
return finish(b, fb.TargetRequest.endTargetRequest(b));
}
export function encodeFriendRespond(requesterId: string, accept: boolean): Uint8Array {
const b = new Builder(64);
const id = b.createString(requesterId);
fb.FriendRespondRequest.startFriendRespondRequest(b);
fb.FriendRespondRequest.addRequesterId(b, id);
fb.FriendRespondRequest.addAccept(b, accept);
return finish(b, fb.FriendRespondRequest.endFriendRespondRequest(b));
}
export function encodeRedeemCode(code: string): Uint8Array {
const b = new Builder(32);
const c = b.createString(code);
fb.RedeemCodeRequest.startRedeemCodeRequest(b);
fb.RedeemCodeRequest.addCode(b, c);
return finish(b, fb.RedeemCodeRequest.endRedeemCodeRequest(b));
}
export function encodeCreateInvitation(inviteeIds: string[], st: InvitationSettings): Uint8Array {
const b = new Builder(256);
const idOffs = inviteeIds.map((id) => b.createString(id));
const ids = fb.CreateInvitationRequest.createInviteeIdsVector(b, idOffs);
const variant = b.createString(st.variant);
const dropout = b.createString(st.dropoutTiles);
fb.CreateInvitationRequest.startCreateInvitationRequest(b);
fb.CreateInvitationRequest.addInviteeIds(b, ids);
fb.CreateInvitationRequest.addVariant(b, variant);
fb.CreateInvitationRequest.addTurnTimeoutSecs(b, st.turnTimeoutSecs);
fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed);
fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer);
fb.CreateInvitationRequest.addDropoutTiles(b, dropout);
return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b));
}
export function encodeInvitationAction(invitationId: string): Uint8Array {
const b = new Builder(64);
const id = b.createString(invitationId);
fb.InvitationActionRequest.startInvitationActionRequest(b);
fb.InvitationActionRequest.addInvitationId(b, id);
return finish(b, fb.InvitationActionRequest.endInvitationActionRequest(b));
}
export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
const b = new Builder(256);
const name = b.createString(p.displayName);
const lang = b.createString(p.preferredLanguage);
const tz = b.createString(p.timeZone);
const as = b.createString(p.awayStart);
const ae = b.createString(p.awayEnd);
fb.UpdateProfileRequest.startUpdateProfileRequest(b);
fb.UpdateProfileRequest.addDisplayName(b, name);
fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
fb.UpdateProfileRequest.addTimeZone(b, tz);
fb.UpdateProfileRequest.addAwayStart(b, as);
fb.UpdateProfileRequest.addAwayEnd(b, ae);
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
}
export function encodeEmailBind(email: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
fb.EmailBindRequest.startEmailBindRequest(b);
fb.EmailBindRequest.addEmail(b, e);
return finish(b, fb.EmailBindRequest.endEmailBindRequest(b));
}
export function encodeEmailConfirm(email: string, code: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const c = b.createString(code);
fb.EmailConfirmRequest.startEmailConfirmRequest(b);
fb.EmailConfirmRequest.addEmail(b, e);
fb.EmailConfirmRequest.addCode(b, c);
return finish(b, fb.EmailConfirmRequest.endEmailConfirmRequest(b));
}
// --- Stage 8 decoders ---
function decodeAccountRef(r: fb.AccountRef): AccountRef {
return { accountId: s(r.accountId()), displayName: s(r.displayName()) };
}
export function decodeFriendList(buf: Uint8Array): AccountRef[] {
const l = fb.FriendList.getRootAsFriendList(new ByteBuffer(buf));
const out: AccountRef[] = [];
for (let i = 0; i < l.friendsLength(); i++) {
const r = l.friends(i);
if (r) out.push(decodeAccountRef(r));
}
return out;
}
export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
const l = fb.IncomingRequestList.getRootAsIncomingRequestList(new ByteBuffer(buf));
const out: AccountRef[] = [];
for (let i = 0; i < l.requestsLength(); i++) {
const r = l.requests(i);
if (r) out.push(decodeAccountRef(r));
}
return out;
}
export function decodeBlockList(buf: Uint8Array): AccountRef[] {
const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
const out: AccountRef[] = [];
for (let i = 0; i < l.blockedLength(); i++) {
const r = l.blocked(i);
if (r) out.push(decodeAccountRef(r));
}
return out;
}
export function decodeFriendCode(buf: Uint8Array): FriendCode {
const c = fb.FriendCode.getRootAsFriendCode(new ByteBuffer(buf));
return { code: s(c.code()), expiresAtUnix: Number(c.expiresAtUnix()) };
}
export function decodeRedeemResult(buf: Uint8Array): AccountRef {
const r = fb.RedeemResult.getRootAsRedeemResult(new ByteBuffer(buf));
const f = r.friend();
return f ? decodeAccountRef(f) : { accountId: '', displayName: '' };
}
export function decodeStats(buf: Uint8Array): Stats {
const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
return {
wins: v.wins(),
losses: v.losses(),
draws: v.draws(),
maxGamePoints: v.maxGamePoints(),
maxWordPoints: v.maxWordPoints(),
};
}
function decodeInvitationTable(i: fb.Invitation): Invitation {
const inviter = i.inviter();
const invitees: InvitationInvitee[] = [];
for (let k = 0; k < i.inviteesLength(); k++) {
const iv = i.invitees(k);
if (iv) {
invitees.push({
accountId: s(iv.accountId()),
displayName: s(iv.displayName()),
seat: iv.seat(),
response: s(iv.response()),
});
}
}
return {
id: s(i.id()),
inviter: inviter ? decodeAccountRef(inviter) : { accountId: '', displayName: '' },
invitees,
variant: s(i.variant()) as Variant,
turnTimeoutSecs: i.turnTimeoutSecs(),
hintsAllowed: i.hintsAllowed(),
hintsPerPlayer: i.hintsPerPlayer(),
dropoutTiles: s(i.dropoutTiles()),
status: s(i.status()),
gameId: s(i.gameId()),
expiresAtUnix: Number(i.expiresAtUnix()),
};
}
export function decodeInvitation(buf: Uint8Array): Invitation {
return decodeInvitationTable(fb.Invitation.getRootAsInvitation(new ByteBuffer(buf)));
}
export function decodeInvitationList(buf: Uint8Array): Invitation[] {
const l = fb.InvitationList.getRootAsInvitationList(new ByteBuffer(buf));
const out: Invitation[] = [];
for (let i = 0; i < l.invitationsLength(); i++) {
const inv = l.invitations(i);
if (inv) out.push(decodeInvitationTable(inv));
}
return out;
}
export function decodeGcg(buf: Uint8Array): GcgExport {
const g = fb.GcgExport.getRootAsGcgExport(new ByteBuffer(buf));
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
}
function emptyGame(): GameView {
return {
id: '',
variant: 'english',
dictVersion: '',
status: '',
players: 0,
toMove: 0,
turnTimeoutSecs: 0,
moveCount: 0,
endReason: '',
seats: [],
};
}
function emptyMove(): MoveRecord {
return { player: 0, action: '', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
}