9814d78ae3
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.
398 lines
13 KiB
Go
398 lines
13 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 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 Mini App launch data via the connector side-service.
|
|
// *connector.Client implements it; a nil value disables the telegram auth path.
|
|
type TelegramValidator interface {
|
|
ValidateInitData(ctx context.Context, initData 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.
|
|
func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *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[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)
|
|
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
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return encodeSession(sess), nil
|
|
}
|
|
}
|
|
|
|
func authGuestHandler(backend *backendclient.Client) Handler {
|
|
return func(ctx context.Context, _ Request) ([]byte, error) {
|
|
sess, err := backend.GuestAuth(ctx)
|
|
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())); 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()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return encodeSession(sess), 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()))
|
|
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 placed tiles from a SubmitPlayRequest.
|
|
func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON {
|
|
n := in.TilesLength()
|
|
tiles := make([]backendclient.TileJSON, 0, n)
|
|
var t fb.TileRecord
|
|
for i := 0; i < n; i++ {
|
|
if in.Tiles(&t, i) {
|
|
tiles = append(tiles, backendclient.TileJSON{
|
|
Row: int(t.Row()),
|
|
Col: int(t.Col()),
|
|
Letter: string(t.Letter()),
|
|
Blank: t.Blank(),
|
|
})
|
|
}
|
|
}
|
|
return tiles
|
|
}
|
|
|
|
// decodeEvalTiles reads the tentative tiles from an EvalRequest.
|
|
func decodeEvalTiles(in *fb.EvalRequest) []backendclient.TileJSON {
|
|
n := in.TilesLength()
|
|
tiles := make([]backendclient.TileJSON, 0, n)
|
|
var t fb.TileRecord
|
|
for i := 0; i < n; i++ {
|
|
if in.Tiles(&t, i) {
|
|
tiles = append(tiles, backendclient.TileJSON{
|
|
Row: int(t.Row()),
|
|
Col: int(t.Col()),
|
|
Letter: string(t.Letter()),
|
|
Blank: t.Blank(),
|
|
})
|
|
}
|
|
}
|
|
return tiles
|
|
}
|
|
|
|
// decodeStringVector reads the exchange tiles from an ExchangeRequest.
|
|
func decodeStringVector(in *fb.ExchangeRequest) []string {
|
|
n := in.TilesLength()
|
|
out := make([]string, 0, n)
|
|
for i := 0; i < n; i++ {
|
|
out = append(out, string(in.Tiles(i)))
|
|
}
|
|
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()), decodeStringVector(in))
|
|
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()), string(in.Word()))
|
|
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
|
|
}
|
|
}
|