From 6679260d0a9110aea3411f10ee66a1a3c04bfd28 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 15:49:26 +0200 Subject: [PATCH] feat(session): carry the bot service_language on the Session wire 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. --- backend/internal/server/dto.go | 18 ++++++++++-------- backend/internal/server/handlers_auth.go | 1 + gateway/internal/backendclient/api.go | 9 +++++---- gateway/internal/transcode/encode.go | 2 ++ .../transcode/transcode_telegram_test.go | 7 ++++++- pkg/fbs/scrabble.fbs | 4 ++++ pkg/fbs/scrabblefb/Session.go | 13 ++++++++++++- ui/src/gen/fbs/scrabblefb/session.ts | 16 ++++++++++++++-- ui/src/lib/codec.test.ts | 3 ++- ui/src/lib/codec.ts | 1 + ui/src/lib/mock/data.ts | 1 + ui/src/lib/model.ts | 3 +++ 12 files changed, 61 insertions(+), 17 deletions(-) diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index bf92a15..3f1b475 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -16,10 +16,11 @@ import ( // 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"` + Token string `json:"token"` + UserID string `json:"user_id"` + IsGuest bool `json:"is_guest"` + DisplayName string `json:"display_name"` + ServiceLanguage string `json:"service_language"` } // okResponse is a simple success acknowledgement. @@ -159,10 +160,11 @@ type errorBody struct { // 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, + Token: token, + UserID: acc.ID.String(), + IsGuest: acc.IsGuest, + DisplayName: acc.DisplayName, + ServiceLanguage: acc.ServiceLanguage, } } diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index 8a9b993..1fed516 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -48,6 +48,7 @@ func (s *Server) handleTelegramAuth(c *gin.Context) { s.abortErr(c, err) return } + acc.ServiceLanguage = req.ServiceLanguage // reflect this login's bot in the session response s.mintSession(c, acc) } diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index e1e034f..4c09e45 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -14,10 +14,11 @@ import ( // 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"` + 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. diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 0a293f2..062fff4 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -36,6 +36,7 @@ func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte token := b.CreateString(s.Token) uid := b.CreateString(s.UserID) name := b.CreateString(s.DisplayName) + svcLang := b.CreateString(s.ServiceLanguage) langs := buildSupportedLanguagesVector(b, supportedLangs) fb.SessionStart(b) fb.SessionAddToken(b, token) @@ -43,6 +44,7 @@ func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte fb.SessionAddIsGuest(b, s.IsGuest) fb.SessionAddDisplayName(b, name) fb.SessionAddSupportedLanguages(b, langs) + fb.SessionAddServiceLanguage(b, svcLang) b.Finish(fb.SessionEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/transcode_telegram_test.go b/gateway/internal/transcode/transcode_telegram_test.go index ff26edd..4849056 100644 --- a/gateway/internal/transcode/transcode_telegram_test.go +++ b/gateway/internal/transcode/transcode_telegram_test.go @@ -43,7 +43,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) { t.Errorf("unexpected path %q", r.URL.Path) } _ = json.NewDecoder(r.Body).Decode(&gotBody) - _, _ = w.Write([]byte(`{"token":"tok-tg","user_id":"u-tg","is_guest":false,"display_name":"Иван"}`)) + _, _ = w.Write([]byte(`{"token":"tok-tg","user_id":"u-tg","is_guest":false,"display_name":"Иван","service_language":"ru"}`)) }) defer cleanup() @@ -67,6 +67,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) { if got := sessionLanguages(sess); len(got) != 1 || got[0] != "ru" { t.Errorf("session supported_languages = %v, want [ru]", got) } + // The bot's service language rides the Session so the UI can build a share link to + // the same bot. + if got := string(sess.ServiceLanguage()); got != "ru" { + t.Errorf("session service_language = %q, 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" { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index d451d79..b098ff0 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -123,6 +123,10 @@ table Session { is_guest:bool; display_name:string; supported_languages:[string]; + // service_language is the language tag (en/ru) of the Telegram bot the session was + // minted through; empty for a non-Telegram login. The UI uses it to build a share + // link that points at the same bot. + service_language:string; } // Ack is a simple success acknowledgement (e.g. an email-code request). diff --git a/pkg/fbs/scrabblefb/Session.go b/pkg/fbs/scrabblefb/Session.go index d1b2fc8..98a12fd 100644 --- a/pkg/fbs/scrabblefb/Session.go +++ b/pkg/fbs/scrabblefb/Session.go @@ -94,8 +94,16 @@ func (rcv *Session) SupportedLanguagesLength() int { return 0 } +func (rcv *Session) ServiceLanguage() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func SessionStart(builder *flatbuffers.Builder) { - builder.StartObject(5) + builder.StartObject(6) } func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0) @@ -115,6 +123,9 @@ func SessionAddSupportedLanguages(builder *flatbuffers.Builder, supportedLanguag func SessionStartSupportedLanguagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func SessionAddServiceLanguage(builder *flatbuffers.Builder, serviceLanguage flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(serviceLanguage), 0) +} func SessionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/gen/fbs/scrabblefb/session.ts b/ui/src/gen/fbs/scrabblefb/session.ts index bb33637..4b752fe 100644 --- a/ui/src/gen/fbs/scrabblefb/session.ts +++ b/ui/src/gen/fbs/scrabblefb/session.ts @@ -58,8 +58,15 @@ supportedLanguagesLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +serviceLanguage():string|null +serviceLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +serviceLanguage(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startSession(builder:flatbuffers.Builder) { - builder.startObject(5); + builder.startObject(6); } static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) { @@ -94,18 +101,23 @@ static startSupportedLanguagesVector(builder:flatbuffers.Builder, numElems:numbe builder.startVector(4, numElems, 4); } +static addServiceLanguage(builder:flatbuffers.Builder, serviceLanguageOffset:flatbuffers.Offset) { + builder.addFieldOffset(5, serviceLanguageOffset, 0); +} + static endSession(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset:flatbuffers.Offset):flatbuffers.Offset { +static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset:flatbuffers.Offset, serviceLanguageOffset:flatbuffers.Offset):flatbuffers.Offset { Session.startSession(builder); Session.addToken(builder, tokenOffset); Session.addUserId(builder, userIdOffset); Session.addIsGuest(builder, isGuest); Session.addDisplayName(builder, displayNameOffset); Session.addSupportedLanguages(builder, supportedLanguagesOffset); + Session.addServiceLanguage(builder, serviceLanguageOffset); return Session.endSession(builder); } } diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 5bd3516..f737ed9 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -171,6 +171,7 @@ describe('codec', () => { isGuest: true, displayName: 'Me', supportedLanguages: ['en', 'ru'], + serviceLanguage: '', }); }); @@ -311,7 +312,7 @@ describe('codec', () => { b.finish(fb.LinkResult.endLinkResult(b)); const r = decodeLinkResult(b.asUint8Array()); expect(r.status).toBe('merged'); - expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [] }); + expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [], serviceLanguage: '' }); }); it('decodes an Invitation with inviter and invitees', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 4b8f916..d6537e0 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -292,6 +292,7 @@ function sessionFromTable(t: fb.Session): Session { isGuest: t.isGuest(), displayName: s(t.displayName()), supportedLanguages, + serviceLanguage: s(t.serviceLanguage()), }; } diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 602ec13..f5af53c 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -25,6 +25,7 @@ export const SESSION: Session = { displayName: 'You', // Both languages by default, so the mock-driven UI offers every variant. supportedLanguages: ['en', 'ru'], + serviceLanguage: '', }; export const PROFILE: Profile = { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 5726802..d3dbfd4 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -234,6 +234,9 @@ export interface Session { // variants these languages support (en -> English; ru -> Russian + Эрудит). Empty // means ungated (all variants). supportedLanguages: string[]; + // serviceLanguage is the en/ru tag of the Telegram bot this session was minted + // through (empty for a non-Telegram login); used to share a link to the same bot. + serviceLanguage: string; } // LinkResult is the outcome of an account link/merge step. status is