feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.
- Telegram: one bot; drop service_language and the supported_languages
set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
connector proto). The single bot renders chat and out-of-app push in
the recipient's preferred_language; remove the game-language push
routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
{erudit_ru}, CHECK non-empty + subset of the three variants). Gates
the New Game picker, vs-AI and the friend invitation the player
creates, enforced server-side (HTTP 400 otherwise); an invited friend
may still accept any variant. Edited on the Settings screen; variants
are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
backend, ui, UI_DESIGN, PRERELEASE).
The migration squash is deferred to a follow-up PR.
This commit is contained in:
@@ -88,13 +88,9 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
||||
}
|
||||
}
|
||||
|
||||
// bannerLang resolves the message language for a viewer: the bot (service)
|
||||
// language they last signed in through, falling back to the interface language
|
||||
// and then English.
|
||||
// bannerLang resolves the message language for a viewer: their interface language
|
||||
// (Russian, or English by default).
|
||||
func bannerLang(acc account.Account) string {
|
||||
if acc.ServiceLanguage == "ru" || acc.ServiceLanguage == "en" {
|
||||
return acc.ServiceLanguage
|
||||
}
|
||||
if acc.PreferredLanguage == "ru" {
|
||||
return "ru"
|
||||
}
|
||||
|
||||
@@ -16,11 +16,10 @@ 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"`
|
||||
ServiceLanguage string `json:"service_language"`
|
||||
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.
|
||||
@@ -49,6 +48,10 @@ type profileResponse struct {
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
// VariantPreferences is the set of game variants the player allows themselves to
|
||||
// be matched into (engine.Variant stable labels), Erudit-first. It gates the New
|
||||
// Game picker and the matchmaker; never empty.
|
||||
VariantPreferences []string `json:"variant_preferences"`
|
||||
// Banner is the advertising-banner block: present only for a viewer eligible to
|
||||
// see the banner (a free account with an empty hint wallet and without the
|
||||
// no_banner role), absent otherwise. See banner.go.
|
||||
@@ -177,11 +180,10 @@ 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,
|
||||
ServiceLanguage: acc.ServiceLanguage,
|
||||
Token: token,
|
||||
UserID: acc.ID.String(),
|
||||
IsGuest: acc.IsGuest,
|
||||
DisplayName: acc.DisplayName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +201,7 @@ func profileResponseFor(acc account.Account) profileResponse {
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
IsGuest: acc.IsGuest,
|
||||
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
||||
VariantPreferences: acc.VariantPreferences,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,14 +18,15 @@ import (
|
||||
// updateProfileRequest is the full editable profile. away_start/away_end are
|
||||
// "HH:MM" local-time bounds of the daily away window.
|
||||
type updateProfileRequest struct {
|
||||
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"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
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"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
VariantPreferences []string `json:"variant_preferences"`
|
||||
}
|
||||
|
||||
// statsDTO is a durable account's lifetime statistics (the derived games-played and
|
||||
@@ -92,6 +93,7 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
|
||||
BlockChat: req.BlockChat,
|
||||
BlockFriendRequests: req.BlockFriendRequests,
|
||||
NotificationsInAppOnly: req.NotificationsInAppOnly,
|
||||
VariantPreferences: req.VariantPreferences,
|
||||
})
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
|
||||
@@ -424,7 +424,6 @@ 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)
|
||||
@@ -436,14 +435,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, language)
|
||||
delivered, err := s.connector.SendToUser(ctx, ext, text)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
body := "message delivered"
|
||||
if !delivered {
|
||||
body = "not delivered (the user may not have started that bot)"
|
||||
body = "not delivered (the user may not have started the bot)"
|
||||
}
|
||||
s.renderConsoleMessage(c, "Sent", body, back)
|
||||
}
|
||||
@@ -833,21 +832,20 @@ 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, language)
|
||||
delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
body := "posted to the game channel"
|
||||
if !delivered {
|
||||
body = "not delivered (that bot has no game channel configured)"
|
||||
body = "not delivered (the bot has no game channel configured)"
|
||||
}
|
||||
s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast")
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
|
||||
}
|
||||
view := adminconsole.FeedbackDetailView{
|
||||
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
|
||||
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang, BotLanguage: m.ChannelLang,
|
||||
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang,
|
||||
IP: m.SenderIP, Body: m.Body,
|
||||
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
|
||||
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
|
||||
|
||||
@@ -19,20 +19,16 @@ 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"`
|
||||
ServiceLanguage string `json:"service_language"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
}
|
||||
|
||||
// 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 and recording the validating bot's
|
||||
// service language (updated every login) so out-of-app push routes to that bot.
|
||||
// language from the supplied Telegram fields (first contact only).
|
||||
func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||
var req telegramAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
@@ -44,11 +40,6 @@ 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
|
||||
}
|
||||
acc.ServiceLanguage = req.ServiceLanguage // reflect this login's bot in the session response
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
@@ -59,10 +50,9 @@ 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 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.
|
||||
// identity, e.g. a guest or email-only account), the language the single bot renders
|
||||
// the message in (the account's interface language), and whether they confined
|
||||
// notifications to the in-app stream.
|
||||
type pushTargetResponse struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
Language string `json:"language"`
|
||||
@@ -94,15 +84,9 @@ 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: language,
|
||||
Language: acc.PreferredLanguage,
|
||||
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,6 +104,9 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
|
||||
abortBadRequest(c, "unknown variant")
|
||||
return
|
||||
}
|
||||
if !s.ensureVariantAllowed(c, uid, variant.String()) {
|
||||
return
|
||||
}
|
||||
settings := lobby.InvitationSettings{
|
||||
Variant: variant,
|
||||
HintsAllowed: req.HintsAllowed,
|
||||
|
||||
@@ -2,8 +2,10 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
@@ -134,6 +136,24 @@ func (s *Server) handleGameState(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// ensureVariantAllowed reports whether the caller's profile permits creating a game
|
||||
// of variant — it must be one of their VariantPreferences. It aborts the request
|
||||
// with 400 and returns false otherwise. Only the player who creates a game (quick
|
||||
// match, vs-AI or a friend invitation) is gated this way; an invited friend may
|
||||
// accept an invitation in any variant.
|
||||
func (s *Server) ensureVariantAllowed(c *gin.Context, uid uuid.UUID, variant string) bool {
|
||||
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return false
|
||||
}
|
||||
if !slices.Contains(acc.VariantPreferences, variant) {
|
||||
abortBadRequest(c, "variant is not in your preferences")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// enqueueRequest enters per-variant auto-match under a per-turn word rule. VsAI true
|
||||
// starts an honest-AI game against a robot instead of the open/wait matchmaking path.
|
||||
type enqueueRequest struct {
|
||||
@@ -162,6 +182,9 @@ func (s *Server) handleEnqueue(c *gin.Context) {
|
||||
abortBadRequest(c, "unknown variant")
|
||||
return
|
||||
}
|
||||
if !s.ensureVariantAllowed(c, uid, variant.String()) {
|
||||
return
|
||||
}
|
||||
if !s.ensureUnderGameLimit(c, uid) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user