// 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" ) // 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[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 } // 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 } } 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 } }