6679260d0a
Thread the Telegram bot's service language (en/ru) from the session mint response through the gateway into the FlatBuffers Session, so the UI knows which bot the player signed in through. handleTelegramAuth refreshes the account's service language onto the response before minting (it was set after the fetched copy). Empty for a non-Telegram login.
446 lines
17 KiB
Go
446 lines
17 KiB
Go
package backendclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
)
|
|
|
|
// The structs below mirror the backend's JSON DTOs (backend/internal/server
|
|
// /dto.go). The transcode layer maps them to and from the FlatBuffers edge
|
|
// payloads.
|
|
|
|
// SessionResp is the credential minted by an auth operation.
|
|
type SessionResp struct {
|
|
Token string `json:"token"`
|
|
UserID string `json:"user_id"`
|
|
IsGuest bool `json:"is_guest"`
|
|
DisplayName string `json:"display_name"`
|
|
ServiceLanguage string `json:"service_language"`
|
|
}
|
|
|
|
// ProfileResp is an account's own profile.
|
|
type ProfileResp struct {
|
|
UserID string `json:"user_id"`
|
|
DisplayName string `json:"display_name"`
|
|
PreferredLanguage string `json:"preferred_language"`
|
|
TimeZone string `json:"time_zone"`
|
|
AwayStart string `json:"away_start"`
|
|
AwayEnd string `json:"away_end"`
|
|
HintBalance int `json:"hint_balance"`
|
|
BlockChat bool `json:"block_chat"`
|
|
BlockFriendRequests bool `json:"block_friend_requests"`
|
|
IsGuest bool `json:"is_guest"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
}
|
|
|
|
// LinkResultResp is the result of an account link/merge step. Status is
|
|
// "linked", "merge_required" (the secondary_* fields summarise the other account) or
|
|
// "merged". Token is a switched-session token (a guest initiator's durable
|
|
// counterpart won); Profile is the surviving/active account's profile.
|
|
type LinkResultResp struct {
|
|
Status string `json:"status"`
|
|
SecondaryUserID string `json:"secondary_user_id"`
|
|
SecondaryName string `json:"secondary_display_name"`
|
|
SecondaryGames int `json:"secondary_games"`
|
|
SecondaryFriends int `json:"secondary_friends"`
|
|
Token string `json:"token"`
|
|
Profile *ProfileResp `json:"profile"`
|
|
}
|
|
|
|
// TileJSON is one tile in a decoded move response (history, move result, hint); its Letter
|
|
// is a concrete character (the move journal is kept in letters).
|
|
type TileJSON struct {
|
|
Row int `json:"row"`
|
|
Col int `json:"col"`
|
|
Letter string `json:"letter"`
|
|
Blank bool `json:"blank"`
|
|
}
|
|
|
|
// PlayTileJSON is one inbound tile to place, addressed by alphabet index. For a
|
|
// blank, Letter is the designated letter's index and Blank is true.
|
|
type PlayTileJSON struct {
|
|
Row int `json:"row"`
|
|
Col int `json:"col"`
|
|
Letter int `json:"letter"`
|
|
Blank bool `json:"blank"`
|
|
}
|
|
|
|
// MoveRecordResp is a decoded move.
|
|
type MoveRecordResp struct {
|
|
Player int `json:"player"`
|
|
Action string `json:"action"`
|
|
Dir string `json:"dir"`
|
|
MainRow int `json:"main_row"`
|
|
MainCol int `json:"main_col"`
|
|
Tiles []TileJSON `json:"tiles"`
|
|
Words []string `json:"words"`
|
|
Count int `json:"count"`
|
|
Score int `json:"score"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// SeatResp is one seat's public standing.
|
|
type SeatResp struct {
|
|
Seat int `json:"seat"`
|
|
AccountID string `json:"account_id"`
|
|
DisplayName string `json:"display_name"`
|
|
Score int `json:"score"`
|
|
HintsUsed int `json:"hints_used"`
|
|
IsWinner bool `json:"is_winner"`
|
|
}
|
|
|
|
// GameResp is the shared game summary.
|
|
type GameResp struct {
|
|
ID string `json:"id"`
|
|
Variant string `json:"variant"`
|
|
DictVersion string `json:"dict_version"`
|
|
Status string `json:"status"`
|
|
Players int `json:"players"`
|
|
ToMove int `json:"to_move"`
|
|
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
|
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
|
MoveCount int `json:"move_count"`
|
|
EndReason string `json:"end_reason"`
|
|
LastActivityUnix int64 `json:"last_activity_unix"`
|
|
Seats []SeatResp `json:"seats"`
|
|
}
|
|
|
|
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
|
|
// wire alphabet indices and BagLen the bag size after the draw.
|
|
type MoveResultResp struct {
|
|
Move MoveRecordResp `json:"move"`
|
|
Game GameResp `json:"game"`
|
|
Rack []int `json:"rack"`
|
|
BagLen int `json:"bag_len"`
|
|
}
|
|
|
|
// AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and
|
|
// tile value), present in StateResp only when the client requested it.
|
|
type AlphabetEntryJSON struct {
|
|
Index int `json:"index"`
|
|
Letter string `json:"letter"`
|
|
Value int `json:"value"`
|
|
}
|
|
|
|
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
|
// Alphabet is present only when the request asked for it.
|
|
type StateResp struct {
|
|
Game GameResp `json:"game"`
|
|
Seat int `json:"seat"`
|
|
Rack []int `json:"rack"`
|
|
BagLen int `json:"bag_len"`
|
|
HintsRemaining int `json:"hints_remaining"`
|
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
|
}
|
|
|
|
// MatchResp reports an auto-match outcome.
|
|
type MatchResp struct {
|
|
Matched bool `json:"matched"`
|
|
Game *GameResp `json:"game,omitempty"`
|
|
}
|
|
|
|
// ChatResp is a stored chat message.
|
|
type ChatResp struct {
|
|
ID string `json:"id"`
|
|
GameID string `json:"game_id"`
|
|
SenderID string `json:"sender_id"`
|
|
Kind string `json:"kind"`
|
|
Body string `json:"body"`
|
|
CreatedAtUnix int64 `json:"created_at_unix"`
|
|
}
|
|
|
|
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
|
// brand-new account's display name and language from the validated launch fields and
|
|
// recording the validating bot's serviceLanguage (which routes the account's later
|
|
// out-of-app push).
|
|
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, serviceLanguage string) (SessionResp, error) {
|
|
var out SessionResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
|
map[string]string{
|
|
"external_id": externalID,
|
|
"language_code": languageCode,
|
|
"username": username,
|
|
"first_name": firstName,
|
|
"service_language": serviceLanguage,
|
|
}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
|
// external_id (empty when they have no Telegram identity), preferred language, and
|
|
// whether they confined notifications to the in-app stream.
|
|
type PushTargetResp struct {
|
|
ExternalID string `json:"external_id"`
|
|
Language string `json:"language"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
}
|
|
|
|
// PushTarget resolves a user id to their out-of-app Telegram routing data (the
|
|
// gateway uses it to decide whether to deliver an event over platform push).
|
|
func (c *Client) PushTarget(ctx context.Context, userID string) (PushTargetResp, error) {
|
|
var out PushTargetResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/push-target", "", "",
|
|
map[string]string{"user_id": userID}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// GuestAuth provisions a guest account and mints a session.
|
|
func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) {
|
|
var out SessionResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", struct{}{}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// EmailRequest asks the backend to mail a login code.
|
|
func (c *Client) EmailRequest(ctx context.Context, email string) error {
|
|
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
|
|
map[string]string{"email": email}, nil)
|
|
}
|
|
|
|
// EmailLogin verifies a login code and mints a session.
|
|
func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionResp, error) {
|
|
var out SessionResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
|
|
map[string]string{"email": email, "code": code}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// ResolveSession maps a token to its account id and guest flag (gateway
|
|
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
|
|
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
|
|
var out struct {
|
|
UserID string `json:"user_id"`
|
|
IsGuest bool `json:"is_guest"`
|
|
}
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
|
map[string]string{"token": token}, &out)
|
|
return out.UserID, out.IsGuest, err
|
|
}
|
|
|
|
// Profile returns the authenticated account's profile.
|
|
func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error) {
|
|
var out ProfileResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/profile", userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// BlockStatusResp is the caller's current manual-block state. Until is an RFC3339 UTC instant for
|
|
// a temporary block, empty for a permanent one or when not blocked; Reason is resolved to the
|
|
// account's language, empty when none was cited.
|
|
type BlockStatusResp struct {
|
|
Blocked bool `json:"blocked"`
|
|
Permanent bool `json:"permanent"`
|
|
Until string `json:"until"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// BlockStatus fetches the caller's current block. It is the one user endpoint a blocked account
|
|
// can still reach (the backend's suspension gate exempts it), so the UI can render the blocked
|
|
// screen.
|
|
func (c *Client) BlockStatus(ctx context.Context, userID string) (BlockStatusResp, error) {
|
|
var out BlockStatusResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/block-status", userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
|
|
// index.
|
|
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
|
var out MoveResultResp
|
|
body := map[string]any{"tiles": tiles}
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
|
|
return out, err
|
|
}
|
|
|
|
// GameState returns the player's view of a game. When includeAlphabet is set the backend
|
|
// embeds the variant's alphabet table; the client asks for it on a per-variant
|
|
// cache miss only.
|
|
func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAlphabet bool) (StateResp, error) {
|
|
var out StateResp
|
|
path := "/api/v1/user/games/" + url.PathEscape(gameID) + "/state"
|
|
if includeAlphabet {
|
|
path += "?include_alphabet=true"
|
|
}
|
|
err := c.do(ctx, http.MethodGet, path, userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Enqueue joins the auto-match pool for a variant under a per-turn word rule
|
|
// (multipleWords true is standard Scrabble, false the single-word rule).
|
|
func (c *Client) Enqueue(ctx context.Context, userID, variant string, multipleWords bool) (MatchResp, error) {
|
|
var out MatchResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/lobby/enqueue", userID, "",
|
|
map[string]any{"variant": variant, "multiple_words_per_turn": multipleWords}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Poll reports whether the caller has been paired since queueing.
|
|
func (c *Client) Poll(ctx context.Context, userID string) (MatchResp, error) {
|
|
var out MatchResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/lobby/poll", userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Cancel removes the caller from the auto-match pool (idempotent; 204 No Content).
|
|
func (c *Client) Cancel(ctx context.Context, userID string) error {
|
|
return c.do(ctx, http.MethodPost, "/api/v1/user/lobby/cancel", userID, "", nil, nil)
|
|
}
|
|
|
|
// ChatPost stores a chat message, forwarding the client IP for moderation.
|
|
func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP string) (ChatResp, error) {
|
|
var out ChatResp
|
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/chat", userID, clientIP,
|
|
map[string]string{"body": body}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// HintResultResp is the top-ranked move plus the remaining hint budget.
|
|
type HintResultResp struct {
|
|
Move MoveRecordResp `json:"move"`
|
|
HintsRemaining int `json:"hints_remaining"`
|
|
}
|
|
|
|
// EvalResultResp is an unlimited move preview. Dir is the orientation the backend
|
|
// inferred ("H"/"V", empty when illegal); Words lists the words formed, main word first.
|
|
type EvalResultResp struct {
|
|
Legal bool `json:"legal"`
|
|
Score int `json:"score"`
|
|
Words []string `json:"words"`
|
|
Dir string `json:"dir"`
|
|
}
|
|
|
|
// WordCheckResp is a dictionary lookup outcome.
|
|
type WordCheckResp struct {
|
|
Word string `json:"word"`
|
|
Legal bool `json:"legal"`
|
|
}
|
|
|
|
// HistoryResp is a game's decoded move journal.
|
|
type HistoryResp struct {
|
|
GameID string `json:"game_id"`
|
|
Moves []MoveRecordResp `json:"moves"`
|
|
}
|
|
|
|
// GameListResp is the caller's games for the lobby.
|
|
type GameListResp struct {
|
|
Games []GameResp `json:"games"`
|
|
}
|
|
|
|
// ChatListResp is a game's chat history.
|
|
type ChatListResp struct {
|
|
Messages []ChatResp `json:"messages"`
|
|
}
|
|
|
|
func (c *Client) gamePath(gameID, suffix string) string {
|
|
return "/api/v1/user/games/" + url.PathEscape(gameID) + suffix
|
|
}
|
|
|
|
// Pass forfeits the player's turn.
|
|
func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultResp, error) {
|
|
var out MoveResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/pass"), userID, "", struct{}{}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Exchange swaps the chosen rack tiles back into the bag. Tiles are wire alphabet indices
|
|
// (a blank is engine.BlankIndex).
|
|
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []int) (MoveResultResp, error) {
|
|
var out MoveResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
|
|
map[string]any{"tiles": tiles}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Resign resigns the player from the game.
|
|
func (c *Client) Resign(ctx context.Context, userID, gameID string) (MoveResultResp, error) {
|
|
var out MoveResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/resign"), userID, "", struct{}{}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Hint reveals the top-ranked move and spends a hint.
|
|
func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultResp, error) {
|
|
var out HintResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hint"), userID, "", struct{}{}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// GetDraft returns the player's saved composition for a game as the backend's
|
|
// raw JSON body. The gateway forwards it verbatim, never interpreting its shape.
|
|
func (c *Client) GetDraft(ctx context.Context, userID, gameID string) (json.RawMessage, error) {
|
|
var out json.RawMessage
|
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/draft"), userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// SaveDraft upserts the player's composition for a game. body is the client's
|
|
// {rack_order, board_tiles} JSON, forwarded verbatim — a json.RawMessage marshals as-is, so
|
|
// there is no double-encode.
|
|
func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json.RawMessage) error {
|
|
return c.do(ctx, http.MethodPut, c.gamePath(gameID, "/draft"), userID, "", body, nil)
|
|
}
|
|
|
|
// HideGame hides a finished game from the caller's own games list. The action is
|
|
// per-account and irreversible; the game stays visible to the other players.
|
|
func (c *Client) HideGame(ctx context.Context, userID, gameID string) error {
|
|
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hide"), userID, "", struct{}{}, nil)
|
|
}
|
|
|
|
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
|
// alphabet index.
|
|
func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
|
var out EvalResultResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
|
map[string]any{"tiles": tiles}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
|
|
// repeated ?idx= alphabet indices; the backend echoes the decoded concrete word.
|
|
func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
|
|
var out WordCheckResp
|
|
q := url.Values{}
|
|
for _, x := range word {
|
|
q.Add("idx", strconv.Itoa(x))
|
|
}
|
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/check_word")+"?"+q.Encode(), userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Complaint disputes a word-check result.
|
|
func (c *Client) Complaint(ctx context.Context, userID, gameID, word, note string) error {
|
|
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/complaint"), userID, "",
|
|
map[string]string{"word": word, "note": note}, nil)
|
|
}
|
|
|
|
// History returns a game's decoded move journal.
|
|
func (c *Client) History(ctx context.Context, userID, gameID string) (HistoryResp, error) {
|
|
var out HistoryResp
|
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/history"), userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// ChatList returns a game's chat history.
|
|
func (c *Client) ChatList(ctx context.Context, userID, gameID string) (ChatListResp, error) {
|
|
var out ChatListResp
|
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/chat"), userID, "", nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Nudge posts a nudge to the player whose turn is awaited.
|
|
func (c *Client) Nudge(ctx context.Context, userID, gameID string) (ChatResp, error) {
|
|
var out ChatResp
|
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/nudge"), userID, "", struct{}{}, &out)
|
|
return out, err
|
|
}
|
|
|
|
// GamesList returns the caller's active and finished games.
|
|
func (c *Client) GamesList(ctx context.Context, userID string) (GameListResp, error) {
|
|
var out GameListResp
|
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/games", userID, "", nil, &out)
|
|
return out, err
|
|
}
|