Files
scrabble-game/gateway/internal/transcode/transcode.go
T
Ilia Denisov 1507ceb793
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
feat(payments): live payment-availability kill switch + per-account override
Let an operator disable purchases live from the admin — a whole rail/channel or
one account — and show the user a localized reason on their next attempt, so a
provider outage or a misconfig is explained instead of a silent dead button.

- rail kill switch (payments.rail_status, per rail direct:web / direct:android /
  vk / telegram): enabled + a per-language message, edited on the catalog page.
  Fail-open — a rail with no row stays enabled, so payments are never accidentally
  killed. The intake gate (CanPurchase in handleWalletOrder, before the order)
  returns payment_unavailable + the localized message, orthogonal to the security
  gates.
- per-account override (payments.account_payment_override, a row only for
  non-default): allow / deny / default, edited on the user card. "allow" bypasses
  ONLY the ops rail switch, never the security gates (trusted platform, the email
  anchor, the VK-iOS freeze, the min client version).
- wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe);
  the gateway forwards a backend domain-error message; the client shows it on a
  payment_unavailable buy attempt.
- admin: rail toggles on the catalog page, the override control on the user card.
- tests: the pure gate (unit, TDD), the store + gate + override end-to-end
  (integration, migration 00016), the client (svelte-check / vitest).
- docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays
  cabinet-side (owner decision) — no itemized-receipt code.

Contour-safe: additive migration (two new tables, no wipe), the wire add is
additive, and fail-open so nothing is disabled until an operator acts.
2026-07-14 16:29:41 +02:00

726 lines
26 KiB
Go

// 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 vertical-slice pattern.
package transcode
import (
"context"
"encoding/json"
"errors"
"net/url"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/connector"
"scrabble/gateway/internal/vkauth"
"scrabble/gateway/internal/vkid"
fb "scrabble/pkg/fbs/scrabblefb"
)
// Message types in the vertical slice.
const (
MsgAuthTelegram = "auth.telegram"
MsgAuthVK = "auth.vk"
MsgAuthGuest = "auth.guest"
MsgAuthEmailReq = "auth.email.request"
MsgAuthEmailLogin = "auth.email.login"
MsgAuthEmailConfirmLink = "auth.email.confirm_link"
MsgProfileGet = "profile.get"
MsgBlockStatus = "account.block_status"
MsgGameSubmitPlay = "game.submit_play"
MsgGameState = "game.state"
MsgLobbyEnqueue = "lobby.enqueue"
MsgLobbyCancel = "lobby.cancel"
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"
MsgChatRead = "chat.read"
MsgDraftGet = "draft.get"
MsgDraftSave = "draft.save"
MsgGameHide = "game.hide"
MsgFeedbackSubmit = "feedback.submit"
MsgFeedbackGet = "feedback.get"
MsgFeedbackUnread = "feedback.unread"
MsgWalletGet = "wallet.get"
MsgWalletCatalog = "wallet.catalog"
MsgWalletBuy = "wallet.buy"
MsgWalletOrder = "wallet.order"
MsgWalletReward = "wallet.reward"
)
// 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
// NonGuest marks an operation a guest account may not perform; the gateway
// rejects it with the guest_forbidden result code after resolving the session,
// before any backend call.
NonGuest 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).
// *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. Optional ops
// (e.g. WithVKAuth) are applied last from opts.
func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Option) *Registry {
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)}
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true}
r.ops[MsgAuthEmailConfirmLink] = Op{Handler: authEmailConfirmLinkHandler(backend)}
r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true}
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, nil), Auth: true}
r.ops[MsgWalletReward] = Op{Handler: walletRewardHandler(backend), Auth: true}
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(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[MsgLobbyCancel] = Op{Handler: cancelHandler(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}
r.ops[MsgChatRead] = Op{Handler: markChatReadHandler(backend), Auth: true}
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
r.ops[MsgFeedbackSubmit] = Op{Handler: feedbackSubmitHandler(backend), Auth: true, NonGuest: true}
r.ops[MsgFeedbackGet] = Op{Handler: feedbackGetHandler(backend), Auth: true}
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
registerSocialOps(r, backend)
registerLinkOps(r, backend, tg)
for _, opt := range opts {
opt(r, backend)
}
return r
}
// Option configures an optional registry operation at construction. It is kept out of
// NewRegistry's positional signature so existing call sites stay unaffected.
type Option func(r *Registry, backend *backendclient.Client)
// WithVKAuth registers the VK Mini App auth op (auth.vk), which verifies launch params
// in-process under the VK app secret. A blank secret leaves auth.vk unregistered, so
// the op is simply unknown wherever VK is not configured.
func WithVKAuth(secret string) Option {
return func(r *Registry, backend *backendclient.Client) {
if secret != "" {
r.ops[MsgAuthVK] = Op{Handler: authVKHandler(backend, secret)}
}
}
}
// WithVKLink registers the VK web-link ops (link.vk.confirm/merge), which complete a
// browser VK ID authorization via the server-side code exchange. A nil exchanger leaves
// them unregistered, so the ops are unknown wherever VK ID web login is not configured.
func WithVKLink(ex VKIDExchanger) Option {
return func(r *Registry, backend *backendclient.Client) {
registerVKLinkOps(r, backend, ex)
}
}
// WithTelegramStars re-registers the wallet.order op with a Telegram Stars invoice minter (the
// bot-link), so a Telegram-context order mints its Stars invoice link through the bot. A nil minter
// is a no-op, leaving the default (Stars-unavailable) handler in place.
func WithTelegramStars(minter InvoiceMinter) Option {
return func(r *Registry, backend *backendclient.Client) {
if minter != nil {
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, minter), Auth: true}
}
}
}
// 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
}
// DomainMessage returns the human-readable, already-localized reason a backend domain error carries
// for the user (e.g. an operator's payment-unavailable explanation), or "" when it carries none. It
// rides the Execute envelope's message field alongside the result code.
func DomainMessage(err error) string {
var apiErr *backendclient.APIError
if errors.As(err, &apiErr) {
return apiErr.Message
}
return ""
}
// 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, vkauth.ErrInvalid) {
return "invalid_vk_params", true
}
if errors.Is(err, vkid.ErrInvalid) {
return "invalid_vk_id", 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)
initData := string(in.InitData())
user, err := tg.ValidateInitData(ctx, initData)
if err != nil {
return nil, err
}
// start_param rides inside the signed initData validated just above, so the launch
// deep-link payload can be trusted here without a separate wire field; the backend
// uses it to seed a brand-new account's variant preferences.
startParam := ""
if q, perr := url.ParseQuery(initData); perr == nil {
startParam = q.Get("start_param")
}
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam, string(in.Subtype()))
if err != nil {
return nil, err
}
return encodeSession(sess), nil
}
}
// authVKHandler verifies a VK Mini App launch in-process (HMAC over the signed vk_*
// params under the app secret) and provisions/finds the bound account. Unlike Telegram,
// VK omits the user's name from the signed params, so the client-supplied display_name
// rides the wire as a cosmetic seed for a brand-new account.
func authVKHandler(backend *backendclient.Client, secret string) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsVKLoginRequest(req.Payload, 0)
user, err := vkauth.Verify(string(in.Params()), secret)
if err != nil {
return nil, err
}
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()), user.Subtype())
if err != nil {
return nil, err
}
return encodeSession(sess), nil
}
}
func authGuestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
// The guest bootstrap historically carried no payload; the detected zone is
// optional, so an absent or empty one simply yields no time-zone seed (rather
// than panicking in GetRootAs* on a zero-length buffer).
var browserTz, subtype string
if len(req.Payload) > 0 {
g := fb.GetRootAsGuestLoginRequest(req.Payload, 0)
browserTz = string(g.BrowserTz())
subtype = string(g.Subtype())
}
sess, err := backend.GuestAuth(ctx, browserTz, subtype)
if err != nil {
return nil, err
}
return encodeSession(sess), 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()), string(in.BrowserTz()), string(in.Language()), in.Pwa()); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func authEmailLoginHandler(backend *backendclient.Client) 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()), string(in.Subtype()))
if err != nil {
return nil, err
}
return encodeSession(sess), nil
}
}
func authEmailConfirmLinkHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailConfirmLinkRequest(req.Payload, 0)
res, err := backend.EmailConfirmLink(ctx, string(in.Token()))
if err != nil {
return nil, err
}
return encodeConfirmLinkResult(res), 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 walletHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
w, err := backend.Wallet(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeWallet(w), nil
}
}
func walletCatalogHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
cat, err := backend.Catalog(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeCatalog(cat), nil
}
}
func walletBuyHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsWalletBuyRequest(req.Payload, 0)
w, err := backend.WalletBuy(ctx, req.UserID, string(in.ProductId()))
if err != nil {
return nil, err
}
return encodeWallet(w), nil
}
}
// InvoiceMinter mints a Telegram Stars invoice link for a created order via the bot (only the bot
// reaches Telegram). The gateway bot-link Hub implements it; it is nil where the bot-link is off,
// which leaves the Telegram rail unavailable.
type InvoiceMinter interface {
MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error)
}
// errTelegramStarsUnavailable is returned when a Telegram Stars order is requested but no invoice
// minter (the bot-link) is wired — the rail is not available on this gateway.
var errTelegramStarsUnavailable = errors.New("telegram stars rail unavailable")
func walletOrderHandler(backend *backendclient.Client, minter InvoiceMinter) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsWalletOrderRequest(req.Payload, 0)
o, err := backend.WalletOrder(ctx, req.UserID, string(in.ProductId()))
if err != nil {
return nil, err
}
// Telegram Stars: mint the invoice link here via the bot-link and return it to the client
// as the redirect URL it opens with WebApp.openInvoice. The title doubles as the invoice
// description (both are required and the pack title is the whole product).
if o.Rail == "telegram" {
if minter == nil {
return nil, errTelegramStarsUnavailable
}
link, merr := minter.MintInvoice(ctx, o.InvoiceTitle, o.InvoiceTitle, o.OrderID, o.InvoiceAmount)
if merr != nil {
return nil, merr
}
o.RedirectURL = link
}
return encodeWalletOrder(o), nil
}
}
// walletRewardHandler credits a watched rewarded video and returns the updated wallet. The nonce is
// the client's per-view idempotency key.
func walletRewardHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsWalletRewardRequest(req.Payload, 0)
w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce()))
if err != nil {
return nil, err
}
return encodeWallet(w), nil
}
}
func blockStatusHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
bs, err := backend.BlockStatus(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeBlockStatus(bs), 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()), 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()), in.MultipleWordsPerTurn(), in.VsAi())
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
}
}
// cancelHandler removes the caller from the auto-match pool. It carries no result;
// it echoes an empty (unmatched) Match so the client has a well-formed payload.
func cancelHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
if err := backend.Cancel(ctx, req.UserID); err != nil {
return nil, err
}
return encodeMatch(backendclient.MatchResp{}), 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.
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.
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 (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()), 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
}
}
// markChatReadHandler acknowledges the caller has read the game's chat. It reuses
// GameActionRequest for the game id and echoes an Ack.
func markChatReadHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
if err := backend.MarkChatRead(ctx, req.UserID, string(in.GameId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
// getDraftHandler returns the player's saved composition. It reuses
// GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView.
func getDraftHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
raw, err := backend.GetDraft(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeDraftView(string(raw)), nil
}
}
// saveDraftHandler upserts the player's composition, forwarding the opaque JSON
// string verbatim. It echoes an empty DraftView as a well-formed acknowledgement.
func saveDraftHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsDraftRequest(req.Payload, 0)
if err := backend.SaveDraft(ctx, req.UserID, string(in.GameId()), json.RawMessage(in.Json())); err != nil {
return nil, err
}
return encodeDraftView(""), nil
}
}
// hideGameHandler hides a finished game from the caller's own list. It reuses
// GameActionRequest for the game id and echoes an Ack.
func hideGameHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
if err := backend.HideGame(ctx, req.UserID, string(in.GameId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func feedbackSubmitHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0)
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), string(in.Version()), string(in.BrowserTz()), req.ClientIP); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func feedbackGetHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
st, err := backend.FeedbackGet(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFeedbackState(st), nil
}
}
func feedbackUnreadHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
u, err := backend.FeedbackUnread(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFeedbackUnread(u), nil
}
}