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
@@ -146,6 +146,7 @@ func (s *Server) consoleUserMessage(c *gin.Context) {
}
back := "/_gm/users/" + id.String()
text := trimForm(c, "text")
language := trimForm(c, "language")
switch {
case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", back)
@@ -157,14 +158,14 @@ func (s *Server) consoleUserMessage(c *gin.Context) {
s.renderConsoleMessage(c, "No Telegram", "this account has no Telegram identity", back)
return
}
delivered, err := s.connector.SendToUser(ctx, ext, text)
delivered, err := s.connector.SendToUser(ctx, ext, text, language)
if err != nil {
s.consoleError(c, err)
return
}
body := "message delivered"
if !delivered {
body = "not delivered (the user may not have started the bot)"
body = "not delivered (the user may not have started that bot)"
}
s.renderConsoleMessage(c, "Sent", body, back)
}
@@ -340,20 +341,21 @@ func (s *Server) consoleBroadcast(c *gin.Context) {
// consolePostBroadcast posts an operator message to the connector's game channel.
func (s *Server) consolePostBroadcast(c *gin.Context) {
text := trimForm(c, "text")
language := trimForm(c, "language")
switch {
case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", "/_gm/broadcast")
case s.connector == nil:
s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", "/_gm/broadcast")
default:
delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text)
delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text, language)
if err != nil {
s.consoleError(c, err)
return
}
body := "posted to the game channel"
if !delivered {
body = "not delivered (no game channel configured on the connector)"
body = "not delivered (that bot has no game channel configured)"
}
s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast")
}
+24 -8
View File
@@ -19,16 +19,20 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language (first contact only).
// ServiceLanguage is the validating bot's language tag (en/ru); it is recorded on
// every login (the bot the user last came through) and routes their out-of-app push.
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
ExternalID string `json:"external_id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
ServiceLanguage string `json:"service_language"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
// identity and mints a session for it, seeding a new account's display name and
// language from the supplied Telegram fields.
// language from the supplied Telegram fields and recording the validating bot's
// service language (updated every login) so out-of-app push routes to that bot.
func (s *Server) handleTelegramAuth(c *gin.Context) {
var req telegramAuthRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
@@ -40,6 +44,10 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
s.abortErr(c, err)
return
}
if err := s.accounts.SetServiceLanguage(c.Request.Context(), acc.ID, req.ServiceLanguage); err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
@@ -50,8 +58,10 @@ type pushTargetRequest struct {
// pushTargetResponse carries what the gateway needs to route an out-of-app push:
// the recipient's Telegram external_id (empty when they have no Telegram
// identity, e.g. a guest or email-only account), the preferred language for the
// message template, and whether they confined notifications to the in-app stream.
// identity, e.g. a guest or email-only account), the language that both selects the
// delivering bot and renders the message (the account's service language, the bot
// it last signed in through, falling back to its preferred language), and whether
// they confined notifications to the in-app stream.
type pushTargetResponse struct {
ExternalID string `json:"external_id"`
Language string `json:"language"`
@@ -83,9 +93,15 @@ func (s *Server) handlePushTarget(c *gin.Context) {
s.abortErr(c, err)
return
}
// Route by the bot the user last signed in through; fall back to the interface
// language for an account that has never come through a tagged bot.
language := acc.ServiceLanguage
if language == "" {
language = acc.PreferredLanguage
}
c.JSON(http.StatusOK, pushTargetResponse{
ExternalID: ext,
Language: acc.PreferredLanguage,
Language: language,
NotificationsInAppOnly: acc.NotificationsInAppOnly,
})
}