57c778f9b2
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.
107 lines
3.7 KiB
Go
107 lines
3.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/ads"
|
|
"scrabble/backend/internal/notify"
|
|
)
|
|
|
|
// bannerDTO is the advertising-banner block attached to an eligible viewer's
|
|
// profile: the campaigns to rotate (each already resolved to the viewer's bot
|
|
// language) and the global display timings the client rotator reads. It is
|
|
// absent for a viewer who should see no banner.
|
|
type bannerDTO struct {
|
|
Campaigns []bannerCampaignDTO `json:"campaigns"`
|
|
Timings bannerTimingsDTO `json:"timings"`
|
|
}
|
|
|
|
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
|
|
// weight (for the client's smooth weighted round-robin) and its messages, in
|
|
// display order, already resolved to one language.
|
|
type bannerCampaignDTO struct {
|
|
Weight int `json:"weight"`
|
|
Messages []string `json:"messages"`
|
|
}
|
|
|
|
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
|
type bannerTimingsDTO struct {
|
|
HoldMs int `json:"hold_ms"`
|
|
EdgePauseMs int `json:"edge_pause_ms"`
|
|
ScrollPxPerSec int `json:"scroll_px_per_sec"`
|
|
FadeOutMs int `json:"fade_out_ms"`
|
|
GapMs int `json:"gap_ms"`
|
|
FadeInMs int `json:"fade_in_ms"`
|
|
}
|
|
|
|
// profileResponse builds the account's profile DTO with the advertising-banner block attached when
|
|
// the viewer is eligible. Every endpoint returning the caller's own profile (get, update, link)
|
|
// uses it, so the banner never drops off the client's profile after a non-get profile change (e.g.
|
|
// a language switch).
|
|
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
|
|
r := profileResponseFor(acc)
|
|
r.Banner = s.bannerFor(ctx, acc)
|
|
return r
|
|
}
|
|
|
|
// bannerFor builds the advertising-banner block for the account's profile, or
|
|
// nil when the ads service is not configured or the viewer is not eligible to
|
|
// see a banner. The message language follows the account's bot (service)
|
|
// language, falling back to its interface language and then English. A failure
|
|
// reading roles or campaigns is logged and treated as "no banner" so the profile
|
|
// response still succeeds.
|
|
func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO {
|
|
if s.ads == nil {
|
|
return nil
|
|
}
|
|
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
|
if err != nil {
|
|
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
|
return nil
|
|
}
|
|
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
|
return nil
|
|
}
|
|
set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
|
if err != nil {
|
|
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
|
return nil
|
|
}
|
|
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
|
for _, c := range set {
|
|
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
|
|
}
|
|
return &bannerDTO{
|
|
Campaigns: campaigns,
|
|
Timings: bannerTimingsDTO{
|
|
HoldMs: timings.HoldMs,
|
|
EdgePauseMs: timings.EdgePauseMs,
|
|
ScrollPxPerSec: timings.ScrollPxPerSec,
|
|
FadeOutMs: timings.FadeOutMs,
|
|
GapMs: timings.GapMs,
|
|
FadeInMs: timings.FadeInMs,
|
|
},
|
|
}
|
|
}
|
|
|
|
// bannerLang resolves the message language for a viewer: their interface language
|
|
// (Russian, or English by default).
|
|
func bannerLang(acc account.Account) string {
|
|
if acc.PreferredLanguage == "ru" {
|
|
return "ru"
|
|
}
|
|
return "en"
|
|
}
|
|
|
|
// publishBannerChange emits the in-app "banner eligibility may have changed"
|
|
// re-poll signal to the account, so an open client re-fetches profile.get and
|
|
// shows or hides the banner without a reload. Best-effort (notify.Nop when no
|
|
// notifier is wired).
|
|
func (s *Server) publishBannerChange(id uuid.UUID) {
|
|
s.notifier.Publish(notify.BannerChanged(id))
|
|
}
|