// Package transcode is the gateway's FlatBuffers<->REST bridge. Each message type // maps to a handler that decodes the FlatBuffers request payload, calls the // backend over REST, and encodes the FlatBuffers response. The registry is the // authoritative message_type catalog; new operations are added here following the // same pattern (PLAN.md Stage 6 vertical slice). package transcode import ( "context" "errors" "scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/connector" fb "scrabble/pkg/fbs/scrabblefb" ) // Message types in the vertical slice. const ( MsgAuthTelegram = "auth.telegram" MsgAuthGuest = "auth.guest" MsgAuthEmailReq = "auth.email.request" MsgAuthEmailLogin = "auth.email.login" MsgProfileGet = "profile.get" MsgGameSubmitPlay = "game.submit_play" MsgGameState = "game.state" MsgLobbyEnqueue = "lobby.enqueue" MsgLobbyPoll = "lobby.poll" MsgChatPost = "chat.post" MsgGamesList = "games.list" MsgGamePass = "game.pass" MsgGameExchange = "game.exchange" MsgGameResign = "game.resign" MsgGameHint = "game.hint" MsgGameEvaluate = "game.evaluate" MsgGameCheckWord = "game.check_word" MsgGameComplaint = "game.complaint" MsgGameHistory = "game.history" MsgChatList = "chat.list" MsgChatNudge = "chat.nudge" ) // Request is one decoded Execute call. type Request struct { Payload []byte UserID string // resolved account id; empty for auth (unauthenticated) ops ClientIP string } // Handler runs one operation and returns the FlatBuffers response payload. type Handler func(ctx context.Context, req Request) ([]byte, error) // Op is a registered message type and its policy flags. type Op struct { Handler Handler // Auth marks an operation that requires a resolved session (X-User-ID). Auth bool // Email marks the costly email-code path that gets a stricter rate sub-limit. Email bool } // Registry maps message types to their operations. type Registry struct { ops map[string]Op } // TelegramValidator validates Telegram credentials via the connector side-service: // Mini App launch data (auth) and Login Widget data (linking, Stage 11). // *connector.Client implements it; a nil value disables the telegram auth and // telegram-link paths. type TelegramValidator interface { ValidateInitData(ctx context.Context, initData string) (connector.User, error) ValidateLoginWidget(ctx context.Context, data string) (connector.User, error) } // NewRegistry builds the slice's message-type catalog over the backend client. // The Telegram auth op is registered only when a validator is supplied (the // connector is configured); otherwise auth.telegram is simply unknown. // defaultLanguages is the New Game variant gating set placed on the Session for // non-platform logins (web / email / guest) and on a switched link session; an // empty argument falls back to all languages (matching the config default). func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLanguages ...string) *Registry { if len(defaultLanguages) == 0 { defaultLanguages = []string{"en", "ru"} } r := &Registry{ops: make(map[string]Op)} if tg != nil { r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)} } r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend, defaultLanguages)} r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true} r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend, defaultLanguages), Email: true} r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true} r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true} r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true} r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true} r.ops[MsgGamePass] = Op{Handler: passHandler(backend), Auth: true} r.ops[MsgGameExchange] = Op{Handler: exchangeHandler(backend), Auth: true} r.ops[MsgGameResign] = Op{Handler: resignHandler(backend), Auth: true} r.ops[MsgGameHint] = Op{Handler: hintHandler(backend), Auth: true} r.ops[MsgGameEvaluate] = Op{Handler: evaluateHandler(backend), Auth: true} r.ops[MsgGameCheckWord] = Op{Handler: checkWordHandler(backend), Auth: true} r.ops[MsgGameComplaint] = Op{Handler: complaintHandler(backend), Auth: true} r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true} r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true} r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true} registerStage8(r, backend) registerStage11(r, backend, tg, defaultLanguages) return r } // Lookup returns the operation for messageType, and whether it is registered. func (r *Registry) Lookup(messageType string) (Op, bool) { op, ok := r.ops[messageType] return op, ok } // DomainCode maps an error to a stable result code to surface in the Execute // envelope, reporting false for an unexpected error the caller should treat as a // transport-level internal failure. func DomainCode(err error) (string, bool) { var apiErr *backendclient.APIError if errors.As(err, &apiErr) { return apiErr.Code, true } if errors.Is(err, connector.ErrInvalidInitData) { return "invalid_init_data", true } if errors.Is(err, connector.ErrInvalidLoginWidget) { return "invalid_login_widget", true } return "", false } func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0) user, err := tg.ValidateInitData(ctx, string(in.InitData())) if err != nil { return nil, err } sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, user.ServiceLanguage) if err != nil { return nil, err } return encodeSession(sess, user.SupportedLanguages), nil } } func authGuestHandler(backend *backendclient.Client, supportedLangs []string) Handler { return func(ctx context.Context, _ Request) ([]byte, error) { sess, err := backend.GuestAuth(ctx) if err != nil { return nil, err } return encodeSession(sess, supportedLangs), nil } } func authEmailRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEmailRequestRequest(req.Payload, 0) if err := backend.EmailRequest(ctx, string(in.Email())); err != nil { return nil, err } return encodeAck(true), nil } } func authEmailLoginHandler(backend *backendclient.Client, supportedLangs []string) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEmailLoginRequest(req.Payload, 0) sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code())) if err != nil { return nil, err } return encodeSession(sess, supportedLangs), nil } } func profileHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { p, err := backend.Profile(ctx, req.UserID) if err != nil { return nil, err } return encodeProfile(p), nil } } func submitPlayHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0) res, err := backend.SubmitPlay(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeTiles(in)) if err != nil { return nil, err } return encodeMoveResult(res), nil } } func gameStateHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsStateRequest(req.Payload, 0) st, err := backend.GameState(ctx, req.UserID, string(in.GameId()), in.IncludeAlphabet()) if err != nil { return nil, err } return encodeState(st), nil } } func enqueueHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEnqueueRequest(req.Payload, 0) m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant())) if err != nil { return nil, err } return encodeMatch(m), nil } } func pollHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { m, err := backend.Poll(ctx, req.UserID) if err != nil { return nil, err } return encodeMatch(m), nil } } func chatPostHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsChatPostRequest(req.Payload, 0) c, err := backend.ChatPost(ctx, req.UserID, string(in.GameId()), string(in.Body()), req.ClientIP) if err != nil { return nil, err } return encodeChat(c), nil } } // decodeTiles reads the index-addressed tiles to place from a SubmitPlayRequest (Stage 13). func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.PlayTileJSON { n := in.TilesLength() tiles := make([]backendclient.PlayTileJSON, 0, n) var t fb.PlayTile for i := 0; i < n; i++ { if in.Tiles(&t, i) { tiles = append(tiles, backendclient.PlayTileJSON{ Row: int(t.Row()), Col: int(t.Col()), Letter: int(t.Letter()), Blank: t.Blank(), }) } } return tiles } // decodeEvalTiles reads the index-addressed tentative tiles from an EvalRequest (Stage 13). func decodeEvalTiles(in *fb.EvalRequest) []backendclient.PlayTileJSON { n := in.TilesLength() tiles := make([]backendclient.PlayTileJSON, 0, n) var t fb.PlayTile for i := 0; i < n; i++ { if in.Tiles(&t, i) { tiles = append(tiles, backendclient.PlayTileJSON{ Row: int(t.Row()), Col: int(t.Col()), Letter: int(t.Letter()), Blank: t.Blank(), }) } } return tiles } // bytesToInts widens a FlatBuffers ubyte vector (an alphabet-index list) to []int for the // backend JSON edge (Stage 13: rack-exchange tiles and the word-check query). func bytesToInts(bs []byte) []int { out := make([]int, len(bs)) for i, b := range bs { out[i] = int(b) } return out } func gamesListHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { res, err := backend.GamesList(ctx, req.UserID) if err != nil { return nil, err } return encodeGameList(res), nil } } func passHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.Pass(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeMoveResult(res), nil } } func resignHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.Resign(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeMoveResult(res), nil } } func exchangeHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsExchangeRequest(req.Payload, 0) res, err := backend.Exchange(ctx, req.UserID, string(in.GameId()), bytesToInts(in.TilesBytes())) if err != nil { return nil, err } return encodeMoveResult(res), nil } } func hintHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.Hint(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeHintResult(res), nil } } func evaluateHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEvalRequest(req.Payload, 0) res, err := backend.Evaluate(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeEvalTiles(in)) if err != nil { return nil, err } return encodeEvalResult(res), nil } } func checkWordHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsCheckWordRequest(req.Payload, 0) res, err := backend.CheckWord(ctx, req.UserID, string(in.GameId()), bytesToInts(in.WordBytes())) if err != nil { return nil, err } return encodeWordCheck(res), nil } } func complaintHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsComplaintRequest(req.Payload, 0) if err := backend.Complaint(ctx, req.UserID, string(in.GameId()), string(in.Word()), string(in.Note())); err != nil { return nil, err } return encodeAck(true), nil } } func historyHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.History(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeHistory(res), nil } } func chatListHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.ChatList(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeChatList(res), nil } } func nudgeHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.Nudge(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeChat(res), nil } }