Stage 17: fix the robot-nudge frequency + per-game push language
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s

Two owner-reported defects from a live contour game.

A. Frequency: the robot's proactive nudge fired hourly for 12h+ (a 12h idle threshold
   then the 1h cooldown, uncapped). Replaced with a lengthening, randomized schedule
   (proactiveNudgeGap): the first nudge ~60-90 min into the human's turn, each later gap
   growing toward 1-6h (uniform sample in [60min, ceil], ceil ramping 90min->6h over 12h
   of idle, measured from the previous nudge), so a long wait gets a handful of
   increasingly-spaced reminders instead of a stream.

B. Language: out-of-app push routed by the recipient's GLOBAL service_language
   (last-login-wins), so after re-logging via the RU bot an English game's nudges came
   from the RU bot. Now a game push (your_turn, game_over, nudge, match_found) carries
   the game's own language (engine.Variant.Language) on push.Event, and the gateway
   routes by it (falling back to service_language for non-game pushes). The New-Game
   variant-gating guarantees the game's bot is one the player has started, so delivery is
   never blocked.

Tests: proactiveNudgeGap unit + retimed TestRobotProactiveNudge; TestVariantLanguage;
emit your_turn/game_over language; TestNudgeRoutedByGameLanguage integration. Docs:
ARCHITECTURE (§7 nudge, §10/§13 routing), FUNCTIONAL (+ _ru), PLAN tracker.
This commit is contained in:
Ilia Denisov
2026-06-09 08:06:58 +02:00
parent 265e442252
commit bf7dca0a09
21 changed files with 257 additions and 45 deletions
+14
View File
@@ -1461,6 +1461,20 @@ provided cert) at the contour caddy; prod VPN; rollback.
game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge,
friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby
hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live.
- **Nudge defects (owner-reported, shipped):** two from a live contour game.
**(A) Frequency** — the robot's proactive nudge fired hourly for 12 h+ (12 h idle threshold +
the 1 h cooldown, uncapped). Replaced with a **lengthening, randomized schedule** in the
robot strategy/driver: the first nudge ~60-90 min into the human's turn, each later gap
growing toward 1-6 h (the gap is a uniform sample in `[60 min, ceil]`, `ceil` ramping from
90 min to 6 h over 12 h of idle, measured from the previous nudge), so a long wait gets a
handful of increasingly-spaced reminders. **(B) Language** — the out-of-app push routed by
the recipient's **global `service_language`** (last-login-wins), so after re-logging through
the RU bot an English game's nudges came from the RU bot. Now a game push (your_turn,
game_over, nudge, match_found) carries the **game's own language** (`engine.Variant.Language`)
on `push.Event`, and the gateway routes by it (falling back to `service_language` for
non-game pushes); the New-Game variant-gating guarantees deliverability. Covered by the
`proactiveNudgeGap` unit test, the retimed `TestRobotProactiveNudge`, `TestVariantLanguage`,
emit (`your_turn`/`game_over` language) and a `TestNudgeRoutedByGameLanguage` integration test.
## Deferred TODOs (cross-stage)
+10
View File
@@ -47,6 +47,16 @@ func (v Variant) String() string {
return "unknown"
}
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for
// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the
// matching per-language Telegram bot — by the game, not the recipient's last-login bot (Stage 17).
func (v Variant) Language() string {
if v == VariantEnglish {
return "en"
}
return "ru"
}
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
// (nil, false) for an unrecognised variant.
func (v Variant) ruleset() (*rules.Ruleset, bool) {
+19
View File
@@ -0,0 +1,19 @@
package engine
import "testing"
// TestVariantLanguage checks the variant -> bot-language mapping that routes a game's out-of-app
// push by the game itself (English -> en, the Russian variants -> ru), rather than the recipient's
// last-login bot (Stage 17).
func TestVariantLanguage(t *testing.T) {
cases := map[Variant]string{
VariantEnglish: "en",
VariantRussianScrabble: "ru",
VariantErudit: "ru",
}
for v, want := range cases {
if got := v.Language(); got != want {
t.Errorf("%s.Language() = %q, want %q", v, got, want)
}
}
}
+7
View File
@@ -68,6 +68,10 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0
t.Errorf("your_turn score_line = %q, want 13:19", got)
}
// Routed out-of-app by the game's language (the default Variant is English).
if yourTurn.Language != "en" {
t.Errorf("your_turn language = %q, want en", yourTurn.Language)
}
}
// TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat,
@@ -102,4 +106,7 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) {
if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" {
t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine())
}
if over[winner].Language != "en" || over[loser].Language != "en" {
t.Errorf("game_over languages = %q/%q, want en/en", over[winner].Language, over[loser].Language)
}
}
+20 -2
View File
@@ -222,6 +222,17 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V
return svc.store.GetGameVariant(ctx, gameID)
}
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot
// (Stage 17).
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
v, err := svc.GameVariant(ctx, gameID)
if err != nil {
return "", err
}
return v.Language(), nil
}
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
@@ -367,6 +378,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
for _, s := range post.Seats {
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
}
// Game pushes are routed out-of-app by the game's own language, not the recipient's
// last-login bot (Stage 17).
lang := post.Variant.Language()
switch post.Status {
case StatusActive:
if next, ok := seatAccount(post.Seats, post.ToMove); ok {
@@ -377,14 +391,18 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
word = rec.Words[0]
}
opponent := svc.displayName(ctx, post.Seats, rec.Player)
intents = append(intents, notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove)))
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove))
yourTurn.Language = lang
intents = append(intents, yourTurn)
}
case StatusFinished:
// The game just ended (any path: a closing play, all-pass, resign or timeout). Tell every
// seat, each with their own perspective + recipient-first score, so an offline player gets
// an out-of-app "game over" push (online players take it from the in-app refresh).
for _, s := range post.Seats {
intents = append(intents, notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat)))
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat))
over.Language = lang
intents = append(intents, over)
}
}
svc.pub.Publish(intents...)
+11 -7
View File
@@ -207,8 +207,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
}
}
// TestRobotProactiveNudge checks the robot nudges the human after the idle
// threshold on the human's turn.
// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the
// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed.
func TestRobotProactiveNudge(t *testing.T) {
ctx := context.Background()
svc := newGameService()
@@ -232,14 +232,18 @@ func TestRobotProactiveNudge(t *testing.T) {
t.Fatalf("create: %v", err)
}
// Midnight start, driven 13 hours later (>12h idle) at a daytime hour awake for
// every drift.
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
// A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No
// nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling.
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
setTurnStarted(t, g.ID, start)
robots.Drive(ctx, start.Add(13*time.Hour))
robots.Drive(ctx, start.Add(30*time.Minute))
if n := countNudges(t, g.ID, robotID); n != 0 {
t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n)
}
robots.Drive(ctx, start.Add(2*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 1 {
t.Errorf("robot nudges = %d, want 1 after 13h idle on the human's turn", n)
t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n)
}
}
+26
View File
@@ -502,6 +502,32 @@ func TestRespondPublishesToRequester(t *testing.T) {
}
}
// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so
// it is delivered by the game's bot rather than the recipient's last-login bot (Stage 17).
func TestNudgeRoutedByGameLanguage(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2) // an English game; seat 0 is to move
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
found := false
for _, in := range pub.intents {
if in.Kind == notify.KindNudge {
found = true
if in.Language != "en" {
t.Errorf("nudge language = %q, want en (the game's language)", in.Language)
}
}
}
if !found {
t.Fatal("no nudge intent published")
}
}
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) {
+4 -1
View File
@@ -76,9 +76,12 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
// emitMatchFound pushes match_found to every seat of a freshly started game.
// Emitting to a robot seat is harmless (no client subscription exists for it).
func (m *Matchmaker) emitMatchFound(g game.Game) {
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot (Stage 17)
intents := make([]notify.Intent, 0, len(g.Seats))
for _, s := range g.Seats {
intents = append(intents, notify.MatchFound(s.AccountID, g.ID))
mf := notify.MatchFound(s.AccountID, g.ID)
mf.Language = lang
intents = append(intents, mf)
}
m.pub.Publish(intents...)
}
+5
View File
@@ -52,6 +52,11 @@ type Intent struct {
Kind string
Payload []byte
EventID string
// Language routes an out-of-app push to a specific per-language bot (Stage 17): for a
// game event it is the game's language ("en"/"ru"), so the notification comes from the
// game's bot rather than the recipient's last-login bot. Empty falls back to the
// recipient's service language at the gateway.
Language string
}
// Publisher accepts live-event intents. Implementations must be safe for
+5 -4
View File
@@ -54,10 +54,11 @@ func (s *Service) Subscribe(req *pushv1.SubscribeRequest, stream grpc.ServerStre
return nil
}
ev := &pushv1.Event{
UserId: in.UserID.String(),
Kind: in.Kind,
Payload: in.Payload,
EventId: in.EventID,
UserId: in.UserID.String(),
Kind: in.Kind,
Payload: in.Payload,
EventId: in.EventID,
Language: in.Language,
}
if err := stream.Send(ev); err != nil {
return err
+13 -4
View File
@@ -96,11 +96,20 @@ func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.U
return s.act(ctx, rt, now)
}
// maybeNudge sends a proactive nudge once the human has been idle past the
// threshold. The social service enforces the once-per-hour-per-game limit and
// rejects a nudge on the robot's own turn, so any such rejection is benign.
// maybeNudge sends a proactive nudge on a lengthening, randomized schedule (proactiveNudgeGap):
// the first lands ~60-90 min into the human's turn, and each one waits longer than the last, so a
// long idle turn gets a handful of increasingly-spaced reminders rather than an hourly stream. The
// gap is measured from the previous nudge (or the turn start for the first). The social service
// still enforces the once-per-game floor and rejects a nudge on the robot's own turn, so any such
// rejection is benign.
func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error {
if now.Sub(rt.TurnStartedAt) < proactiveNudgeIdle {
ref := rt.TurnStartedAt
if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil {
return err
} else if ok && last.After(rt.TurnStartedAt) {
ref = last
}
if now.Sub(ref) < proactiveNudgeGap(ref.Sub(rt.TurnStartedAt), rt.Seed) {
return nil
}
if _, err := s.social.Nudge(ctx, rt.GameID, rt.RobotID); err != nil {
+27 -3
View File
@@ -55,9 +55,16 @@ const (
// sleep window relative to the opponent's timezone, in hours.
sleepDriftHours = 3
// proactiveNudgeIdle is how long the robot waits on the human's turn before it
// proactively nudges (subject to the social once-per-hour-per-game limit).
proactiveNudgeIdle = 12 * time.Hour
// The robot proactively nudges the idle human on a lengthening, randomized schedule rather
// than an hourly stream: the first nudge lands ~60-90 min into the turn, and each subsequent
// gap grows toward 1-6 h the longer the wait drags on, so a long idle turn gets only a handful
// of increasingly-spaced reminders. The gap is a uniform sample in [nudgeGapFloorMinutes,
// ceil] minutes, where ceil ramps from nudgeGapFirstCeilMinutes to nudgeGapCeilMinutes over
// nudgeGapRamp of idle.
nudgeGapFloorMinutes = 60.0
nudgeGapFirstCeilMinutes = 90.0
nudgeGapCeilMinutes = 360.0
nudgeGapRamp = 12 * time.Hour
)
// defaultBand is the target resulting score margin after the robot's move: when
@@ -181,6 +188,23 @@ func nudgeReplyDelay(seed int64, moveCount int) time.Duration {
return clampMinutes(lo + nudgeReplySpreadMinutes*u)
}
// proactiveNudgeGap is the randomized wait before the next proactive nudge, given how long the
// human had already been idle at the previous nudge (refIdle; 0 for the first nudge of the turn).
// It is a uniform sample in [nudgeGapFloorMinutes, ceil] minutes, where ceil ramps from
// nudgeGapFirstCeilMinutes (a ~60-90 min first gap) up to nudgeGapCeilMinutes (a 1-6 h gap) as
// refIdle reaches nudgeGapRamp — so the reminders space out the longer the turn is neglected. It
// is deterministic per (seed, refIdle), so the driver computes the same due time on every scan.
func proactiveNudgeGap(refIdle time.Duration, seed int64) time.Duration {
f := float64(refIdle) / float64(nudgeGapRamp)
if f > 1 {
f = 1
}
ceil := nudgeGapFirstCeilMinutes + (nudgeGapCeilMinutes-nudgeGapFirstCeilMinutes)*f
u := unitFloat(mix(seed, "pnudge", int(refIdle/(30*time.Minute))))
mins := nudgeGapFloorMinutes + (ceil-nudgeGapFloorMinutes)*u
return time.Duration(mins * float64(time.Minute))
}
// clampMinutes converts a minute count to a duration, clamping it to the hard delay
// bounds so an out-of-range band can never produce an absurd think time.
func clampMinutes(mins float64) time.Duration {
+32
View File
@@ -238,6 +238,38 @@ func TestPlayToWinExport(t *testing.T) {
}
}
// TestProactiveNudgeGap checks the proactive-nudge schedule: the first gap (refIdle 0) is
// ~60-90 min, every gap stays within [60 min, 6 h] and is deterministic, and the gap lengthens
// as the idle grows (the median at 12 h idle exceeds the median at the start).
func TestProactiveNudgeGap(t *testing.T) {
for seed := int64(1); seed <= 1000; seed++ {
if first := proactiveNudgeGap(0, seed); first < 60*time.Minute || first > 90*time.Minute {
t.Fatalf("first gap %s out of [60m,90m] for seed %d", first, seed)
}
for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} {
g := proactiveNudgeGap(idle, seed)
if g < 60*time.Minute || g > 6*time.Hour {
t.Fatalf("gap %s out of [60m,6h] for seed %d idle %s", g, seed, idle)
}
if proactiveNudgeGap(idle, seed) != g {
t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle)
}
}
}
median := func(idle time.Duration) float64 {
const n = 4000
xs := make([]float64, n)
for s := 0; s < n; s++ {
xs[s] = proactiveNudgeGap(idle, int64(s+1)).Minutes()
}
sort.Float64s(xs)
return xs[n/2]
}
if early, late := median(0), median(12*time.Hour); early >= late {
t.Errorf("median gap should grow with idle: idle0=%.0f idle12h=%.0f", early, late)
}
}
// plays builds candidate plays carrying only the given scores (ranked as passed).
func plays(scores ...int) []engine.MoveRecord {
out := make([]engine.MoveRecord, len(scores))
+5 -1
View File
@@ -130,7 +130,11 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
}
svc.metrics.recordChat(ctx, kindNudge)
if toMove >= 0 && toMove < len(seats) {
svc.pub.Publish(notify.Nudge(seats[toMove], gameID, senderID))
nudge := notify.Nudge(seats[toMove], gameID, senderID)
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
nudge.Language = lang // route by the game's bot, not the recipient's last-login one (Stage 17)
}
svc.pub.Publish(nudge)
}
return msg, nil
}
+3
View File
@@ -31,6 +31,9 @@ type GameReader interface {
// LastMoveAt is the time of an account's most recent move in a game (and whether it
// has moved); the nudge cooldown resets once the player has taken a turn.
LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error)
// GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes
// to the game's bot rather than the recipient's last-login bot (Stage 17).
GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error)
}
// Sentinel errors returned by the service.
+14 -9
View File
@@ -141,8 +141,10 @@ arrive from a platform rather than completing a mandatory registration).
gate (it is a product affordance, not a trust boundary). The service language is
**persisted** per account (`accounts.service_language`, updated on every Telegram
login — last-login-wins) and routes the user's out-of-app push back through the right
bot (§10); it is distinct from `preferred_language` (the interface language) and from
a game's variant language. Non-Telegram logins (web / email / guest) carry the
bot (§10)**except a game event, which routes by the game's own language** (its variant →
en/ru, Stage 17), so a game's notification always comes from the game's bot rather than the
recipient's latest login bot. The service language is distinct from `preferred_language` (the
interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the
gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default).
- The client holds `session_id` in memory for the app session (browser/OS
storage is optional and may be unavailable; losing it means re-login).
@@ -339,8 +341,9 @@ English game the Latin pool.
**sleeps 00:0007:00** anchored to the **opponent's** profile timezone with a
per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's
rather than running anti-phase; on a daytime nudge it replies near the move's lower
band; it proactively nudges the human after **12 hours** idle (subject to the
once-per-hour chat limit).
band; it proactively nudges the idle human on a **lengthening, randomized schedule** the
first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long
wait gets a handful of increasingly-spaced nudges rather than an hourly stream (Stage 17).
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
authoritative balance metric (target ≈ 40% robot wins) — and a
`robot_games_finished_total` OTel counter plus a per-finish log give a live view.
@@ -507,11 +510,13 @@ missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fal
the **gateway** routes from the same firehose: for an event whose recipient has **no
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
`external_id`, the **service language** — the bot they last signed in through, falling
back to the interface language — and the `notifications_in_app_only` flag) and asks the
**Telegram connector** to deliver a localized message with a Mini App deep-link
button — only when the recipient has a Telegram identity and has not confined
notifications to the app, so the two channels never duplicate. The connector routes by
that language to the matching bot and renders the message in it. The out-of-app set is
back to the interface language — and the `notifications_in_app_only` flag). A **game** event,
however, carries the **game's own language** on the push (Stage 17), and the gateway routes by
that instead of the service language — so a game's notification always comes from the game's bot,
not the recipient's latest-login bot. It then asks the **Telegram connector** to deliver a
localized message with a Mini App deep-link button — only when the recipient has a Telegram
identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
your-turn, game-over, nudge, match-found and the invitation / friend-request notify sub-kinds;
the connector renders the message and skips the rest. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
+4 -2
View File
@@ -35,8 +35,10 @@ the Telegram colours, and — on first contact — seeds the new account's inter
language from the Telegram client. The sign-in service also declares the **game
languages** it offers (a set of en/ru, at least one), which gate the New Game variant
choice in the lobby. Telegram runs a separate bot per language (an English bot and a
Russian bot, the same player spanning both); the bot a player signed in through both
sets their offered languages and is the bot their out-of-app notifications come from. Guests are session-only with restricted features
Russian bot, the same player spanning both); the bot a player signed in through sets their
offered languages, and their non-game notifications come from it. A **game's** notifications
(your turn, game over, a nudge), though, always come from **that game's** bot — by the game's
language, not whichever bot the player signed in through last. Guests are session-only with restricted features
(auto-match only; no friends, stats or history); an abandoned guest that never
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
keeps a live stream and receives in-app updates in real time — the opponent's move,
+4 -2
View File
@@ -36,8 +36,10 @@ Mini App** авторизует по подписанным `initData` плат
языку Telegram-клиента. Сервис входа также объявляет **языки игры**, которые он
предлагает (набор из en/ru, минимум один), и они ограничивают выбор типа партии в
лобби. Telegram держит отдельного бота на язык (английский и русский, один игрок
охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки и
является тем ботом, от которого приходят его внеприложенческие уведомления. Гость — только сессия, с урезанными функциями (только
охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки, и от него
приходят его **внеигровые** уведомления. А уведомления по **партии** (ваш ход, конец партии,
nudge) приходят от бота **этой партии** — по языку партии, а не по тому боту, через которого
игрок входил последним. Гость — только сессия, с урезанными функциями (только
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
+9 -3
View File
@@ -214,7 +214,7 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
// deliver the event over the platform push channel. Done in a goroutine
// so a slow connector never stalls the in-app firehose.
if conn != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) {
go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), logger)
go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), ev.GetLanguage(), logger)
}
}
if !sleep(ctx, pushReconnectDelay) {
@@ -227,7 +227,7 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
// Telegram identity and have not confined notifications to the app, asks the
// connector to deliver the event. It is best-effort: every failure is logged and
// dropped (the in-app stream remains the primary channel).
func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *connector.Client, userID, kind string, payload []byte, logger *zap.Logger) {
func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *connector.Client, userID, kind string, payload []byte, gameLang string, logger *zap.Logger) {
target, err := backend.PushTarget(ctx, userID)
if err != nil {
logger.Warn("push target lookup failed", zap.String("user_id", userID), zap.Error(err))
@@ -236,7 +236,13 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *c
if !connector.DeliverToTarget(target.ExternalID, target.NotificationsInAppOnly) {
return
}
if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, target.Language); err != nil {
// A game event carries its own language, so the push comes from the game's bot rather than
// the recipient's last-login bot (Stage 17); other events fall back to the service language.
lang := target.Language
if gameLang != "" {
lang = gameLang
}
if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, lang); err != nil {
logger.Warn("out-of-app notify failed", zap.String("kind", kind), zap.Error(err))
}
}
+20 -7
View File
@@ -79,11 +79,16 @@ func (x *SubscribeRequest) GetGatewayId() string {
// FlatBuffers-encoded body for that kind. event_id is a correlation id the
// gateway carries through to the client envelope.
type Event struct {
state protoimpl.MessageState `protogen:"open.v1"`
UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
EventId string `protobuf:"bytes,4,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
EventId string `protobuf:"bytes,4,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
// language routes an out-of-app push to a specific per-language bot (Stage 17): for a game
// event it carries the game's language ("en"/"ru") so the notification comes from the game's
// bot rather than the recipient's last-login bot. Empty falls back to the recipient's service
// language at the gateway.
Language string `protobuf:"bytes,5,opt,name=language,proto3" json:"language,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -146,6 +151,13 @@ func (x *Event) GetEventId() string {
return ""
}
func (x *Event) GetLanguage() string {
if x != nil {
return x.Language
}
return ""
}
var File_push_v1_push_proto protoreflect.FileDescriptor
const file_push_v1_push_proto_rawDesc = "" +
@@ -153,12 +165,13 @@ const file_push_v1_push_proto_rawDesc = "" +
"\x12push/v1/push.proto\x12\x10scrabble.push.v1\"1\n" +
"\x10SubscribeRequest\x12\x1d\n" +
"\n" +
"gateway_id\x18\x01 \x01(\tR\tgatewayId\"i\n" +
"gateway_id\x18\x01 \x01(\tR\tgatewayId\"\x85\x01\n" +
"\x05Event\x12\x17\n" +
"\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" +
"\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\x12\x19\n" +
"\bevent_id\x18\x04 \x01(\tR\aeventId2R\n" +
"\bevent_id\x18\x04 \x01(\tR\aeventId\x12\x1a\n" +
"\blanguage\x18\x05 \x01(\tR\blanguage2R\n" +
"\x04Push\x12J\n" +
"\tSubscribe\x12\".scrabble.push.v1.SubscribeRequest\x1a\x17.scrabble.push.v1.Event0\x01B#Z!scrabble/pkg/proto/push/v1;pushv1b\x06proto3"
+5
View File
@@ -33,4 +33,9 @@ message Event {
string kind = 2;
bytes payload = 3;
string event_id = 4;
// language routes an out-of-app push to a specific per-language bot (Stage 17): for a game
// event it carries the game's language ("en"/"ru") so the notification comes from the game's
// bot rather than the recipient's last-login bot. Empty falls back to the recipient's service
// language at the gateway.
string language = 5;
}