Merge pull request 'Stage 17: robot-nudge frequency + per-game push language' (#30) from feature/nudge-fix into development
This commit was merged in pull request #30.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:00–07: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
@@ -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,
|
||||
|
||||
@@ -36,8 +36,10 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
языку Telegram-клиента. Сервис входа также объявляет **языки игры**, которые он
|
||||
предлагает (набор из en/ru, минимум один), и они ограничивают выбор типа партии в
|
||||
лобби. Telegram держит отдельного бота на язык (английский и русский, один игрок
|
||||
охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки и
|
||||
является тем ботом, от которого приходят его внеприложенческие уведомления. Гость — только сессия, с урезанными функциями (только
|
||||
охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки, и от него
|
||||
приходят его **внеигровые** уведомления. А уведомления по **партии** (ваш ход, конец партии,
|
||||
nudge) приходят от бота **этой партии** — по языку партии, а не по тому боту, через которого
|
||||
игрок входил последним. Гость — только сессия, с урезанными функциями (только
|
||||
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
|
||||
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
|
||||
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user