Stage 15: dual Telegram bots & language-gated variants
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 10s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s

Service-agnostic refinement of the owner's idea: the sign-in service returns a
set of supported game languages with the user identity, and the lobby gates the
New Game variant choice by it (en -> English; ru -> Russian + Эрудит).

- Connector hosts two bots in one container (one per service language, each its
  own token + game channel; the same telegram_id spans both). ValidateInitData
  tries each token and returns the validating bot's service_language +
  supported_languages. Per-language config (TELEGRAM_BOT_TOKEN_EN/_RU, channels).
- supported_languages rides the Session (fbs, session-scoped, not persisted); the
  UI offers only the matching variants on New Game — gating only the START of a
  new game (auto-match + friend invite), not accept/open/play; backend does not
  enforce.
- service_language persisted (accounts.service_language, migration 00010, written
  every login, last-login-wins) and routes the user-facing Notify push back
  through the right bot (push-target coalesces with preferred_language).
- Admin SendToUser/SendToGameChannel gain an operator-chosen language selector in
  the console (unrelated to ValidateInitData).
- Non-Telegram logins carry the gateway default set
  (GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, all variants).

Wire (committed regen): ValidateInitDataResponse +service_language
+supported_languages; Session +supported_languages; SendToUser/SendToGameChannel
+language. Docs (ARCHITECTURE/FUNCTIONAL/_ru/READMEs) + PLAN updated; stage marked done.
This commit is contained in:
Ilia Denisov
2026-06-05 09:35:53 +02:00
parent 23b5c3b5cc
commit e9f836db87
45 changed files with 1010 additions and 267 deletions
+9 -6
View File
@@ -146,15 +146,18 @@ type ChatResp struct {
}
// 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.
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) {
// 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,
"external_id": externalID,
"language_code": languageCode,
"username": username,
"first_name": firstName,
"service_language": serviceLanguage,
}, &out)
return out, err
}
+41
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
pkgtel "scrabble/pkg/telemetry"
@@ -32,6 +33,11 @@ type Config struct {
// gateway calls it to validate Mini App initData and to deliver out-of-app push.
// Empty disables the telegram auth path and the out-of-app push channel.
ConnectorAddr string
// DefaultSupportedLanguages is the New Game variant gating set put on the Session
// for non-platform logins (web / email / guest), which carry no service container
// to declare one. The UI offers only variants in this set (en -> English; ru ->
// Russian + Эрудит). Defaults to all of them; a deployment may narrow it.
DefaultSupportedLanguages []string
// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
// caps the number of cached sessions.
SessionTTL time.Duration
@@ -71,6 +77,13 @@ const (
defaultServiceName = "scrabble-gateway"
)
// supportedLanguages is the set of game languages a service may declare for the
// New Game variant gating; defaultSupportedLanguages is the non-platform fallback.
var (
supportedLanguages = map[string]bool{"en": true, "ru": true}
defaultSupportedLanguages = []string{"en", "ru"}
)
// DefaultRateLimit returns the built-in anti-abuse limits.
func DefaultRateLimit() RateLimitConfig {
return RateLimitConfig{
@@ -113,6 +126,9 @@ func Load() (Config, error) {
if c.PushHeartbeatInterval, err = envDuration("GATEWAY_PUSH_HEARTBEAT_INTERVAL", defaultPushHeartbeatInterval); err != nil {
return Config{}, err
}
if c.DefaultSupportedLanguages, err = envLanguages("GATEWAY_DEFAULT_SUPPORTED_LANGUAGES", defaultSupportedLanguages); err != nil {
return Config{}, err
}
if err := c.validate(); err != nil {
return Config{}, err
}
@@ -156,6 +172,31 @@ func envOr(key, fallback string) string {
return fallback
}
// envLanguages parses a comma-separated language list (e.g. "en,ru") from the
// environment variable named key, returning fallback when it is unset. Every entry
// must be a supported language and the result must be non-empty.
func envLanguages(key string, fallback []string) ([]string, error) {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback, nil
}
var out []string
for part := range strings.SplitSeq(raw, ",") {
lang := strings.ToLower(strings.TrimSpace(part))
if lang == "" {
continue
}
if !supportedLanguages[lang] {
return nil, fmt.Errorf("config: %s: unsupported language %q", key, lang)
}
out = append(out, lang)
}
if len(out) == 0 {
return nil, fmt.Errorf("config: %s must list at least one language", key)
}
return out, nil
}
// envInt parses the environment variable named key as an int, returning fallback
// when it is unset and an error when it is set but malformed.
func envInt(key string, fallback int) (int, error) {
+17 -9
View File
@@ -27,12 +27,18 @@ var ErrInvalidInitData = errors.New("connector: invalid telegram init data")
// rejects the Login Widget data (a gRPC InvalidArgument).
var ErrInvalidLoginWidget = errors.New("connector: invalid telegram login widget data")
// User is a validated Mini App identity.
// User is a validated Mini App identity. ServiceLanguage is the validating bot's
// language tag (en/ru), persisted to route the user's out-of-app push back through
// the right bot; SupportedLanguages is that bot's set of offered game languages,
// which the UI gates the New Game variant choice by. Both are empty for a Login
// Widget validation (it carries no bot language).
type User struct {
ExternalID string
Username string
FirstName string
LanguageCode string
ExternalID string
Username string
FirstName string
LanguageCode string
ServiceLanguage string
SupportedLanguages []string
}
// Client wraps the connector's Telegram gRPC service.
@@ -67,10 +73,12 @@ func (c *Client) ValidateInitData(ctx context.Context, initData string) (User, e
return User{}, err
}
return User{
ExternalID: resp.GetExternalId(),
Username: resp.GetUsername(),
FirstName: resp.GetFirstName(),
LanguageCode: resp.GetLanguageCode(),
ExternalID: resp.GetExternalId(),
Username: resp.GetUsername(),
FirstName: resp.GetFirstName(),
LanguageCode: resp.GetLanguageCode(),
ServiceLanguage: resp.GetServiceLanguage(),
SupportedLanguages: resp.GetSupportedLanguages(),
}, nil
}
+27 -4
View File
@@ -12,17 +12,36 @@ import (
// created before the table that references it, and no two tables/vectors are
// under construction at once.
// encodeSession builds a Session payload.
func encodeSession(s backendclient.SessionResp) []byte {
// buildSupportedLanguagesVector creates the Session.supported_languages [string]
// vector from langs. FlatBuffers is built bottom-up, so the caller must invoke this
// (which itself creates the element strings) before SessionStart and with no table
// under construction.
func buildSupportedLanguagesVector(b *flatbuffers.Builder, langs []string) flatbuffers.UOffsetT {
offsets := make([]flatbuffers.UOffsetT, len(langs))
for i, lang := range langs {
offsets[i] = b.CreateString(lang)
}
fb.SessionStartSupportedLanguagesVector(b, len(langs))
for i := len(offsets) - 1; i >= 0; i-- {
b.PrependUOffsetT(offsets[i])
}
return b.EndVector(len(langs))
}
// encodeSession builds a Session payload. supportedLangs is the service's set of
// offered game languages, which the UI gates the New Game variant choice by.
func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte {
b := flatbuffers.NewBuilder(128)
token := b.CreateString(s.Token)
uid := b.CreateString(s.UserID)
name := b.CreateString(s.DisplayName)
langs := buildSupportedLanguagesVector(b, supportedLangs)
fb.SessionStart(b)
fb.SessionAddToken(b, token)
fb.SessionAddUserId(b, uid)
fb.SessionAddIsGuest(b, s.IsGuest)
fb.SessionAddDisplayName(b, name)
fb.SessionAddSupportedLanguages(b, langs)
b.Finish(fb.SessionEnd(b))
return b.FinishedBytes()
}
@@ -63,8 +82,10 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
// encodeLinkResult builds a LinkResult payload (Stage 11). A switched-session token
// (a guest initiator whose durable counterpart won) is carried as a nested Session
// for the client to adopt; it is omitted otherwise.
func encodeLinkResult(r backendclient.LinkResultResp) []byte {
// for the client to adopt; it is omitted otherwise. supportedLangs is the variant
// gating set for that switched session — the link flows run on the web, so it is the
// gateway's default (non-platform) set.
func encodeLinkResult(r backendclient.LinkResultResp, supportedLangs []string) []byte {
b := flatbuffers.NewBuilder(256)
status := b.CreateString(r.Status)
secID := b.CreateString(r.SecondaryUserID)
@@ -75,11 +96,13 @@ func encodeLinkResult(r backendclient.LinkResultResp) []byte {
token := b.CreateString(r.Token)
uid := b.CreateString(r.Profile.UserID)
name := b.CreateString(r.Profile.DisplayName)
langs := buildSupportedLanguagesVector(b, supportedLangs)
fb.SessionStart(b)
fb.SessionAddToken(b, token)
fb.SessionAddUserId(b, uid)
fb.SessionAddIsGuest(b, r.Profile.IsGuest)
fb.SessionAddDisplayName(b, name)
fb.SessionAddSupportedLanguages(b, langs)
sess = fb.SessionEnd(b)
}
fb.LinkResultStart(b)
+16 -10
View File
@@ -75,14 +75,20 @@ type TelegramValidator interface {
// 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 {
// defaultLanguages is the New Game variant gating set placed on the Session for
// non-platform logins (web / email / guest) and on a switched link session; an
// empty argument falls back to all languages (matching the config default).
func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLanguages ...string) *Registry {
if len(defaultLanguages) == 0 {
defaultLanguages = []string{"en", "ru"}
}
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[MsgAuthGuest] = Op{Handler: authGuestHandler(backend, defaultLanguages)}
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true}
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend, defaultLanguages), 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}
@@ -101,7 +107,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
registerStage8(r, backend)
registerStage11(r, backend, tg)
registerStage11(r, backend, tg, defaultLanguages)
return r
}
@@ -135,21 +141,21 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
if err != nil {
return nil, err
}
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName)
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, user.ServiceLanguage)
if err != nil {
return nil, err
}
return encodeSession(sess), nil
return encodeSession(sess, user.SupportedLanguages), nil
}
}
func authGuestHandler(backend *backendclient.Client) Handler {
func authGuestHandler(backend *backendclient.Client, supportedLangs []string) Handler {
return func(ctx context.Context, _ Request) ([]byte, error) {
sess, err := backend.GuestAuth(ctx)
if err != nil {
return nil, err
}
return encodeSession(sess), nil
return encodeSession(sess, supportedLangs), nil
}
}
@@ -163,14 +169,14 @@ func authEmailRequestHandler(backend *backendclient.Client) Handler {
}
}
func authEmailLoginHandler(backend *backendclient.Client) Handler {
func authEmailLoginHandler(backend *backendclient.Client, supportedLangs []string) 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
return encodeSession(sess, supportedLangs), nil
}
}
+13 -11
View File
@@ -22,13 +22,15 @@ const (
// registerStage11 adds the linking & merge operations. The telegram ops need the
// connector's Login Widget validator, so they are registered only when tg is set.
func registerStage11(r *Registry, backend *backendclient.Client, tg TelegramValidator) {
// supportedLangs is the variant gating set for a switched link session (the link
// flows run on the web, so the gateway default set).
func registerStage11(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) {
r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend, supportedLangs), Auth: true, Email: true}
r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend, supportedLangs), Auth: true, Email: true}
if tg != nil {
r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true}
r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true}
r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false, supportedLangs), Auth: true}
r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true, supportedLangs), Auth: true}
}
}
@@ -42,31 +44,31 @@ func linkEmailRequestHandler(backend *backendclient.Client) Handler {
}
}
func linkEmailConfirmHandler(backend *backendclient.Client) Handler {
func linkEmailConfirmHandler(backend *backendclient.Client, supportedLangs []string) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
res, err := backend.LinkEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
return encodeLinkResult(res, supportedLangs), nil
}
}
func linkEmailMergeHandler(backend *backendclient.Client) Handler {
func linkEmailMergeHandler(backend *backendclient.Client, supportedLangs []string) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
res, err := backend.LinkEmailMerge(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
return encodeLinkResult(res, supportedLangs), nil
}
}
// linkTelegramHandler validates Login Widget data via the connector and then calls
// the backend's link or merge endpoint with the trusted Telegram external id.
func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool) Handler {
func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool, supportedLangs []string) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkTelegramRequest(req.Payload, 0)
user, err := tg.ValidateLoginWidget(ctx, string(in.Data()))
@@ -82,6 +84,6 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
return encodeLinkResult(res, supportedLangs), nil
}
}
@@ -47,7 +47,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
})
defer cleanup()
v := fakeValidator{user: connector.User{ExternalID: "42", Username: "neo", FirstName: "Иван", LanguageCode: "ru"}}
v := fakeValidator{user: connector.User{ExternalID: "42", Username: "neo", FirstName: "Иван", LanguageCode: "ru", ServiceLanguage: "ru", SupportedLanguages: []string{"ru"}}}
reg := transcode.NewRegistry(backend, v)
op, ok := reg.Lookup(transcode.MsgAuthTelegram)
if !ok {
@@ -62,9 +62,43 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
if string(sess.Token()) != "tok-tg" || string(sess.UserId()) != "u-tg" {
t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId())
}
// The validated launch fields are forwarded so the backend can seed a new account.
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
// The validating bot's supported languages ride the Session so the UI gates the
// New Game variant choice (here: ru -> Russian + Эрудит only).
if got := sessionLanguages(sess); len(got) != 1 || got[0] != "ru" {
t.Errorf("session supported_languages = %v, want [ru]", got)
}
// The validated launch fields are forwarded so the backend can seed a new account;
// service_language is recorded to route the account's later out-of-app push.
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" || gotBody["service_language"] != "ru" {
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван service_language=ru", gotBody)
}
}
// sessionLanguages reads the supported_languages vector off a decoded Session.
func sessionLanguages(s *fb.Session) []string {
out := make([]string, s.SupportedLanguagesLength())
for i := range out {
out[i] = string(s.SupportedLanguages(i))
}
return out
}
// TestGuestAuthSeedsDefaultLanguages confirms a non-platform (guest) session carries
// the gateway's default supported-languages set, so the web client is ungated.
func TestGuestAuthSeedsDefaultLanguages(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok-g","user_id":"u-g","is_guest":true,"display_name":"Guest"}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil, "en", "ru")
op, _ := reg.Lookup(transcode.MsgAuthGuest)
payload, err := op.Handler(context.Background(), transcode.Request{})
if err != nil {
t.Fatalf("handler: %v", err)
}
if got := sessionLanguages(fb.GetRootAsSession(payload, 0)); len(got) != 2 || got[0] != "en" || got[1] != "ru" {
t.Errorf("guest session supported_languages = %v, want [en ru]", got)
}
}