diplomail (Stage E): LibreTranslate client + async translation worker
Synchronous translation on read (Stage D) blocks the HTTP handler on translator I/O. Stage E switches to "send moments-fast, deliver when translated": recipients whose preferred_language differs from the detected body_lang are inserted with available_at=NULL, and an async worker turns them on once a LibreTranslate call materialises the cache row (or fails terminally after 5 retries). Schema delta on diplomail_recipients: available_at, translation_attempts, next_translation_attempt_at, plus a snapshot recipient_preferred_language so the worker queries do not need a join. Read paths (ListInbox, GetMessage, UnreadCount) filter on available_at IS NOT NULL. Push fan-out is moved from Service to the worker so the recipient only sees the toast when the inbox row is actually visible. Translator backend is now a configurable choice: empty BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original); populated → LibreTranslate HTTP client. Per-attempt timeout, max attempts, and worker interval all live in DiplomailConfig. The HTTP client itself is unit-tested via httptest (happy path, BCP47 normalisation, unsupported pair, 5xx, identical src/dst, missing URL); worker delivery + fallback paths are covered by the testcontainers-backed e2e tests in diplomail_e2e_test.go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+43
-19
@@ -309,6 +309,10 @@ func run(ctx context.Context) (err error) {
|
||||
runtimeNotifyPublisher.svc = notifSvc
|
||||
|
||||
diplomailStore := diplomail.NewStore(db)
|
||||
diplomailTranslator, err := buildDiplomailTranslator(cfg.Diplomail, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build diplomail translator: %w", err)
|
||||
}
|
||||
diplomailSvc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomailStore,
|
||||
Memberships: &diplomailMembershipAdapter{lobby: lobbySvc, users: userSvc},
|
||||
@@ -316,11 +320,12 @@ func run(ctx context.Context) (err error) {
|
||||
Entitlements: &diplomailEntitlementAdapter{users: userSvc},
|
||||
Games: &diplomailGameAdapter{lobby: lobbySvc},
|
||||
Detector: detector.New(),
|
||||
Translator: translator.NewNoop(),
|
||||
Translator: diplomailTranslator,
|
||||
Config: cfg.Diplomail,
|
||||
Logger: logger,
|
||||
})
|
||||
lobbyDiplomailPublisher.svc = diplomailSvc
|
||||
diplomailWorker := diplomail.NewWorker(diplomailSvc)
|
||||
if email := cfg.Notification.AdminEmail; email == "" {
|
||||
logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped")
|
||||
} else {
|
||||
@@ -398,7 +403,7 @@ func run(ctx context.Context) (err error) {
|
||||
runtimeScheduler := runtimeSvc.SchedulerComponent()
|
||||
runtimeReconciler := runtimeSvc.Reconciler()
|
||||
|
||||
components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler}
|
||||
components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, diplomailWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler}
|
||||
if metricsServer.Enabled() {
|
||||
components = append(components, metricsServer)
|
||||
}
|
||||
@@ -641,11 +646,12 @@ func (a *diplomailMembershipAdapter) GetActiveMembership(ctx context.Context, ga
|
||||
return diplomail.ActiveMembership{}, err
|
||||
}
|
||||
return diplomail.ActiveMembership{
|
||||
UserID: userID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: found.RaceName,
|
||||
UserID: userID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: found.RaceName,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -677,12 +683,13 @@ func (a *diplomailMembershipAdapter) GetMembershipAnyStatus(ctx context.Context,
|
||||
return diplomail.MemberSnapshot{}, err
|
||||
}
|
||||
return diplomail.MemberSnapshot{
|
||||
UserID: userID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: found.RaceName,
|
||||
Status: found.Status,
|
||||
UserID: userID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: found.RaceName,
|
||||
Status: found.Status,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -720,12 +727,13 @@ func (a *diplomailMembershipAdapter) ListMembers(ctx context.Context, gameID uui
|
||||
return nil, fmt.Errorf("resolve user_name for %s: %w", m.UserID, err)
|
||||
}
|
||||
out = append(out, diplomail.MemberSnapshot{
|
||||
UserID: m.UserID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: m.RaceName,
|
||||
Status: m.Status,
|
||||
UserID: m.UserID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: m.RaceName,
|
||||
Status: m.Status,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@@ -754,6 +762,22 @@ func (a *lobbyDiplomailPublisherAdapter) PublishLifecycle(ctx context.Context, e
|
||||
})
|
||||
}
|
||||
|
||||
// buildDiplomailTranslator selects the diplomail translator backend
|
||||
// from configuration: a non-empty `TranslatorURL` constructs the
|
||||
// LibreTranslate HTTP client; an empty URL falls through to the
|
||||
// noop translator so deployments without a translation service still
|
||||
// boot and deliver mail with the fallback path.
|
||||
func buildDiplomailTranslator(cfg config.DiplomailConfig, logger *zap.Logger) (translator.Translator, error) {
|
||||
if cfg.TranslatorURL == "" {
|
||||
logger.Info("diplomail translator URL not configured, using noop translator")
|
||||
return translator.NewNoop(), nil
|
||||
}
|
||||
return translator.NewLibreTranslate(translator.LibreTranslateConfig{
|
||||
URL: cfg.TranslatorURL,
|
||||
Timeout: cfg.TranslatorTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
// diplomailEntitlementAdapter implements
|
||||
// `diplomail.EntitlementReader` by reading the user-service
|
||||
// entitlement snapshot. The IsPaid flag mirrors the per-tier policy
|
||||
|
||||
Reference in New Issue
Block a user