e22f4b7800
Replaces the LangUndetermined placeholder with whatlanggo-backed body detection on every send path, then adds a translation cache keyed on (message_id, target_lang) populated lazily on the per-message read endpoint. The noop translator that ships with Stage D returns engine="noop", which the service treats as "translation unavailable" — wiring a real backend (LibreTranslate HTTP client is the documented next step) is a one-file swap. GetMessage and ListInbox now accept a targetLang argument; the HTTP layer resolves the caller's accounts.preferred_language and forwards it. Inbox uses the cache only (never calls the translator) so bulk reads stay fast under future SaaS backends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
6.7 KiB
Go
178 lines
6.7 KiB
Go
package diplomail
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"galaxy/backend/internal/config"
|
|
"galaxy/backend/internal/diplomail/detector"
|
|
"galaxy/backend/internal/diplomail/translator"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Deps aggregates every collaborator the diplomail Service depends on.
|
|
//
|
|
// Store and Memberships are required. Logger and Now default to
|
|
// zap.NewNop / time.Now when nil. Notification falls back to a no-op
|
|
// publisher so unit tests can construct a Service with only the
|
|
// required collaborators populated. Entitlements and Games are
|
|
// optional — they are used by Stage C surfaces (paid-tier player
|
|
// broadcast, multi-game admin broadcast, bulk cleanup). Wiring may
|
|
// pass nil for tests that do not exercise those paths.
|
|
type Deps struct {
|
|
Store *Store
|
|
Memberships MembershipLookup
|
|
Notification NotificationPublisher
|
|
Entitlements EntitlementReader
|
|
Games GameLookup
|
|
Detector detector.LanguageDetector
|
|
Translator translator.Translator
|
|
Config config.DiplomailConfig
|
|
Logger *zap.Logger
|
|
Now func() time.Time
|
|
}
|
|
|
|
// EntitlementReader is the read-only surface diplomail uses to gate
|
|
// the paid-tier player broadcast. The canonical implementation in
|
|
// `cmd/backend/main` reads
|
|
// `*user.Service.GetEntitlementSnapshot(userID).IsPaid`.
|
|
type EntitlementReader interface {
|
|
IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error)
|
|
}
|
|
|
|
// GameLookup exposes the slim view of `games` the multi-game admin
|
|
// broadcast and bulk-cleanup paths consume. The canonical
|
|
// implementation walks the lobby cache plus an explicit store call
|
|
// for finished-game pruning.
|
|
type GameLookup interface {
|
|
// ListRunningGames returns every game whose `status` is one of
|
|
// the still-active values (running, paused, starting, …). The
|
|
// admin `all_running` broadcast scope iterates over the result.
|
|
ListRunningGames(ctx context.Context) ([]GameSnapshot, error)
|
|
|
|
// ListFinishedGamesBefore returns every game whose `finished_at`
|
|
// is older than `cutoff`. The bulk-purge admin endpoint reads
|
|
// this to compose the cascade-delete IN list.
|
|
ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameSnapshot, error)
|
|
|
|
// GetGame returns one game snapshot identified by id, or
|
|
// ErrNotFound. Used by the multi-game broadcast to verify the
|
|
// caller-supplied id list before enqueuing fan-out work.
|
|
GetGame(ctx context.Context, gameID uuid.UUID) (GameSnapshot, error)
|
|
}
|
|
|
|
// GameSnapshot is the trim view of `games` consumed by the multi-game
|
|
// admin broadcast and the cleanup paths. The struct intentionally
|
|
// avoids the full `lobby.GameRecord` so the diplomail package stays
|
|
// decoupled from the lobby domain.
|
|
type GameSnapshot struct {
|
|
GameID uuid.UUID
|
|
GameName string
|
|
Status string
|
|
FinishedAt *time.Time
|
|
}
|
|
|
|
// ActiveMembership is the slim view of a single (user, game) roster
|
|
// row the diplomail package needs at send time: it confirms the
|
|
// participant is active in the game and captures the snapshot fields
|
|
// (`game_name`, `user_name`, `race_name`) that we persist on each new
|
|
// message / recipient row.
|
|
type ActiveMembership struct {
|
|
UserID uuid.UUID
|
|
GameID uuid.UUID
|
|
GameName string
|
|
UserName string
|
|
RaceName string
|
|
}
|
|
|
|
// MembershipLookup is the read-only surface diplomail uses to verify
|
|
// "is this user an active member of this game" and to snapshot the
|
|
// roster metadata. The canonical implementation in `cmd/backend/main`
|
|
// adapts the `*lobby.Service` membership cache to this interface.
|
|
//
|
|
// GetActiveMembership returns ErrNotFound (the diplomail sentinel)
|
|
// when the user is not an active member of the game; the service
|
|
// boundary maps that to 403 forbidden.
|
|
//
|
|
// GetMembershipAnyStatus returns the same shape regardless of
|
|
// membership status (`active`, `removed`, `blocked`). Used by the
|
|
// inbox read path to check whether a kicked recipient still belongs
|
|
// to the game's roster; ErrNotFound is surfaced when the user has
|
|
// never been a member.
|
|
//
|
|
// ListMembers returns every roster row matching scope, in stable
|
|
// order. Scope values are `active`, `active_and_removed`, and
|
|
// `all_members` (the spec calls these out by name). Used by the
|
|
// broadcast composition step in admin / owner sends.
|
|
type MembershipLookup interface {
|
|
GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error)
|
|
GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (MemberSnapshot, error)
|
|
ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]MemberSnapshot, error)
|
|
}
|
|
|
|
// Recipient scope values accepted by ListMembers and by the
|
|
// `recipients` request field on admin / owner broadcasts.
|
|
const (
|
|
RecipientScopeActive = "active"
|
|
RecipientScopeActiveAndRemoved = "active_and_removed"
|
|
RecipientScopeAllMembers = "all_members"
|
|
)
|
|
|
|
// MemberSnapshot is the slim view of a membership row that survives
|
|
// all three status values. RaceName is the immutable string captured
|
|
// at registration time; an empty value is legal for rare cases where
|
|
// the row was inserted without one.
|
|
type MemberSnapshot struct {
|
|
UserID uuid.UUID
|
|
GameID uuid.UUID
|
|
GameName string
|
|
UserName string
|
|
RaceName string
|
|
Status string
|
|
}
|
|
|
|
// NotificationPublisher is the outbound surface diplomail uses to
|
|
// emit the `diplomail.message.received` push event. The canonical
|
|
// implementation in `cmd/backend/main` adapts the notification.Service
|
|
// the same way it adapts `lobby.NotificationPublisher`; tests pass
|
|
// the no-op publisher below to avoid wiring the dispatcher.
|
|
type NotificationPublisher interface {
|
|
PublishDiplomailEvent(ctx context.Context, ev DiplomailNotification) error
|
|
}
|
|
|
|
// DiplomailNotification is the open shape carried by a per-recipient
|
|
// push intent. The struct lives in the diplomail package so the
|
|
// producer vocabulary stays here; the publisher adapter translates it
|
|
// into a `notification.Intent` at the wiring boundary.
|
|
type DiplomailNotification struct {
|
|
Kind string
|
|
IdempotencyKey string
|
|
Recipient uuid.UUID
|
|
Payload map[string]any
|
|
}
|
|
|
|
// NewNoopNotificationPublisher returns a publisher that logs every
|
|
// call at debug level and returns nil. Used by unit tests and as the
|
|
// fallback inside NewService when callers leave Deps.Notification nil.
|
|
func NewNoopNotificationPublisher(logger *zap.Logger) NotificationPublisher {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &noopNotificationPublisher{logger: logger.Named("diplomail.notify.noop")}
|
|
}
|
|
|
|
type noopNotificationPublisher struct {
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func (p *noopNotificationPublisher) PublishDiplomailEvent(_ context.Context, ev DiplomailNotification) error {
|
|
p.logger.Debug("noop notification",
|
|
zap.String("kind", ev.Kind),
|
|
zap.String("idempotency_key", ev.IdempotencyKey),
|
|
zap.String("recipient", ev.Recipient.String()),
|
|
)
|
|
return nil
|
|
}
|