3590df28db
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.
263 lines
7.9 KiB
Go
263 lines
7.9 KiB
Go
package server
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/lobby"
|
|
"scrabble/backend/internal/social"
|
|
)
|
|
|
|
// The JSON DTOs below are the gateway<->backend REST contract. They are explicit
|
|
// (the domain/engine structs are never serialised directly) and mirror the
|
|
// FlatBuffers edge tables (pkg/fbs) the gateway transcodes to and from.
|
|
|
|
// sessionResponse is the credential returned by every auth endpoint.
|
|
type sessionResponse struct {
|
|
Token string `json:"token"`
|
|
UserID string `json:"user_id"`
|
|
IsGuest bool `json:"is_guest"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
|
|
// okResponse is a simple success acknowledgement.
|
|
type okResponse struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
|
|
// resolveResponse maps a session token to its account.
|
|
type resolveResponse struct {
|
|
UserID string `json:"user_id"`
|
|
}
|
|
|
|
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
|
|
// are the daily away window's "HH:MM" local-time bounds (in TimeZone).
|
|
type profileResponse 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"`
|
|
}
|
|
|
|
// tileDTO is one placed (or to-place) tile.
|
|
type tileDTO struct {
|
|
Row int `json:"row"`
|
|
Col int `json:"col"`
|
|
Letter string `json:"letter"`
|
|
Blank bool `json:"blank"`
|
|
}
|
|
|
|
// moveRecordDTO is a decoded move (a committed play, or a hint preview).
|
|
type moveRecordDTO struct {
|
|
Player int `json:"player"`
|
|
Action string `json:"action"`
|
|
Dir string `json:"dir"`
|
|
MainRow int `json:"main_row"`
|
|
MainCol int `json:"main_col"`
|
|
Tiles []tileDTO `json:"tiles"`
|
|
Words []string `json:"words"`
|
|
Count int `json:"count"`
|
|
Score int `json:"score"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// seatDTO is one seat's public standing. DisplayName is resolved from the account
|
|
// store by the handler (the game domain keys seats by account id only).
|
|
type seatDTO 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"`
|
|
}
|
|
|
|
// gameDTO is the shared game summary.
|
|
type gameDTO 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"`
|
|
MoveCount int `json:"move_count"`
|
|
EndReason string `json:"end_reason"`
|
|
Seats []seatDTO `json:"seats"`
|
|
}
|
|
|
|
// moveResultDTO is the outcome of a committed move.
|
|
type moveResultDTO struct {
|
|
Move moveRecordDTO `json:"move"`
|
|
Game gameDTO `json:"game"`
|
|
}
|
|
|
|
// stateDTO is a player's view of a game.
|
|
type stateDTO struct {
|
|
Game gameDTO `json:"game"`
|
|
Seat int `json:"seat"`
|
|
Rack []string `json:"rack"`
|
|
BagLen int `json:"bag_len"`
|
|
HintsRemaining int `json:"hints_remaining"`
|
|
}
|
|
|
|
// matchDTO reports whether the caller has been paired into a game.
|
|
type matchDTO struct {
|
|
Matched bool `json:"matched"`
|
|
Game *gameDTO `json:"game,omitempty"`
|
|
}
|
|
|
|
// chatDTO is one stored chat message or nudge.
|
|
type chatDTO 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"`
|
|
}
|
|
|
|
// errorResponse is the uniform error envelope.
|
|
type errorResponse struct {
|
|
Error errorBody `json:"error"`
|
|
}
|
|
|
|
type errorBody struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// sessionResponseFor builds the credential payload for a minted session.
|
|
func sessionResponseFor(token string, acc account.Account) sessionResponse {
|
|
return sessionResponse{
|
|
Token: token,
|
|
UserID: acc.ID.String(),
|
|
IsGuest: acc.IsGuest,
|
|
DisplayName: acc.DisplayName,
|
|
}
|
|
}
|
|
|
|
// profileResponseFor projects an account into its profile DTO.
|
|
func profileResponseFor(acc account.Account) profileResponse {
|
|
return profileResponse{
|
|
UserID: acc.ID.String(),
|
|
DisplayName: acc.DisplayName,
|
|
PreferredLanguage: acc.PreferredLanguage,
|
|
TimeZone: acc.TimeZone,
|
|
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
|
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
|
HintBalance: acc.HintBalance,
|
|
BlockChat: acc.BlockChat,
|
|
BlockFriendRequests: acc.BlockFriendRequests,
|
|
IsGuest: acc.IsGuest,
|
|
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
|
}
|
|
}
|
|
|
|
// awayTimeLayout is the "HH:MM" wire form of the daily away-window bounds.
|
|
const awayTimeLayout = "15:04"
|
|
|
|
// gameDTOFromGame projects a game.Game into its DTO.
|
|
func gameDTOFromGame(g game.Game) gameDTO {
|
|
seats := make([]seatDTO, 0, len(g.Seats))
|
|
for _, s := range g.Seats {
|
|
seats = append(seats, seatDTO{
|
|
Seat: s.Seat,
|
|
AccountID: s.AccountID.String(),
|
|
Score: s.Score,
|
|
HintsUsed: s.HintsUsed,
|
|
IsWinner: s.IsWinner,
|
|
})
|
|
}
|
|
return gameDTO{
|
|
ID: g.ID.String(),
|
|
Variant: g.Variant.String(),
|
|
DictVersion: g.DictVersion,
|
|
Status: g.Status,
|
|
Players: g.Players,
|
|
ToMove: g.ToMove,
|
|
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
|
MoveCount: g.MoveCount,
|
|
EndReason: g.EndReason,
|
|
Seats: seats,
|
|
}
|
|
}
|
|
|
|
// moveRecordDTOFrom projects an engine move record into its DTO.
|
|
func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO {
|
|
tiles := make([]tileDTO, 0, len(m.Tiles))
|
|
for _, t := range m.Tiles {
|
|
tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
|
}
|
|
return moveRecordDTO{
|
|
Player: m.Player,
|
|
Action: m.Action.String(),
|
|
Dir: m.Dir.String(),
|
|
MainRow: m.MainRow,
|
|
MainCol: m.MainCol,
|
|
Tiles: tiles,
|
|
Words: m.Words,
|
|
Count: m.Count,
|
|
Score: m.Score,
|
|
Total: m.Total,
|
|
}
|
|
}
|
|
|
|
// moveResultDTOFrom projects a committed move result into its DTO.
|
|
func moveResultDTOFrom(r game.MoveResult) moveResultDTO {
|
|
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
|
|
}
|
|
|
|
// stateDTOFrom projects a player's state view into its DTO.
|
|
func stateDTOFrom(v game.StateView) stateDTO {
|
|
return stateDTO{
|
|
Game: gameDTOFromGame(v.Game),
|
|
Seat: v.Seat,
|
|
Rack: v.Rack,
|
|
BagLen: v.BagLen,
|
|
HintsRemaining: v.HintsRemaining,
|
|
}
|
|
}
|
|
|
|
// matchDTOFrom projects an enqueue/poll result into its DTO.
|
|
func matchDTOFrom(r lobby.EnqueueResult) matchDTO {
|
|
if !r.Matched {
|
|
return matchDTO{Matched: false}
|
|
}
|
|
g := gameDTOFromGame(r.Game)
|
|
return matchDTO{Matched: true, Game: &g}
|
|
}
|
|
|
|
// chatDTOFrom projects a chat message into its DTO.
|
|
func chatDTOFrom(m social.Message) chatDTO {
|
|
return chatDTO{
|
|
ID: m.ID.String(),
|
|
GameID: m.GameID.String(),
|
|
SenderID: m.SenderID.String(),
|
|
Kind: m.Kind,
|
|
Body: m.Body,
|
|
CreatedAtUnix: m.CreatedAt.Unix(),
|
|
}
|
|
}
|
|
|
|
// parseDirection maps the wire direction string to an engine.Direction.
|
|
func parseDirection(s string) (engine.Direction, bool) {
|
|
switch strings.ToUpper(strings.TrimSpace(s)) {
|
|
case "H":
|
|
return engine.Horizontal, true
|
|
case "V":
|
|
return engine.Vertical, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|