diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index bc22532..05da93a 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -27,6 +27,8 @@ import ( "galaxy/backend/internal/config" "galaxy/backend/internal/devsandbox" "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/diplomail/detector" + "galaxy/backend/internal/diplomail/translator" "galaxy/backend/internal/dockerclient" "galaxy/backend/internal/engineclient" "galaxy/backend/internal/geo" @@ -313,6 +315,8 @@ func run(ctx context.Context) (err error) { Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc}, Entitlements: &diplomailEntitlementAdapter{users: userSvc}, Games: &diplomailGameAdapter{lobby: lobbySvc}, + Detector: detector.New(), + Translator: translator.NewNoop(), Config: cfg.Diplomail, Logger: logger, }) diff --git a/backend/go.mod b/backend/go.mod index da040c6..68e062e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -36,6 +36,7 @@ require ( ) require ( + github.com/abadojack/whatlanggo v1.0.1 // indirect github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index e39284a..ff077fd 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -10,6 +10,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o= github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI= +github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= +github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= diff --git a/backend/internal/diplomail/README.md b/backend/internal/diplomail/README.md index 7ea3e8c..761223a 100644 --- a/backend/internal/diplomail/README.md +++ b/backend/internal/diplomail/README.md @@ -17,7 +17,7 @@ purge, and the language-detection / translation cache. | A | Schema, personal single-recipient send / read / delete, unread badge, push event with body-language `und` | shipped | | B | Owner / admin sends + lifecycle hooks (paused, cancelled, kick); strict soft-access for kicked players | shipped | | C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge + admin observability | shipped | -| D | Body-language detection (whatlanggo) + translation cache + async worker | planned | +| D | Body-language detection (whatlanggo) + translation cache + lazy per-read translator dispatch | shipped | ## Tables @@ -67,8 +67,32 @@ mail to every active member; `Service.changeMembershipStatus` / `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256). Both limits live in the service layer so they can be tuned without a schema migration. -- `body_lang` is stored as the BCP 47 `und` (undetermined) sentinel - until Stage D wires the auto-detector. +- `body_lang` is filled at send time by the configured + `detector.LanguageDetector` (default: `whatlanggo`, body-only, + ≥ 25 runes; shorter bodies stay `und`). + +## Translation + +Stage D adds a lazy translation cache. When a recipient reads a +message through `GET /api/v1/user/games/{game_id}/mail/messages/{id}`, +the handler resolves the caller's `accounts.preferred_language` and +asks `Service.GetMessage(…, targetLang)` to attach a translation: + +- on cache hit (row in `diplomail_translations`), the rendering is + returned directly under `translated_subject` / `translated_body`; +- on cache miss, the configured `translator.Translator` is invoked. + A non-noop result is persisted and returned to the caller; the + noop translator that ships with Stage D returns `engine == "noop"`, + which is treated as "translation unavailable" and the caller falls + back to the original body. + +The inbox listing (`/inbox`) reuses cached translations but never +calls the translator on miss — bulk listings stay fast even when a +real translator (LibreTranslate, SaaS engine) introduces I/O cost. + +Future work plugs a real `translator.Translator` (LibreTranslate +HTTP client is the documented next step) without touching the rest +of the system. ## Push integration diff --git a/backend/internal/diplomail/admin_send.go b/backend/internal/diplomail/admin_send.go index cb8793f..2d8b36b 100644 --- a/backend/internal/diplomail/admin_send.go +++ b/backend/internal/diplomail/admin_send.go @@ -157,7 +157,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas SenderIP: in.SenderIP, Subject: subject, Body: body, - BodyLang: LangUndetermined, + BodyLang: s.deps.Detector.Detect(body), BroadcastScope: BroadcastScopeGameBroadcast, } rcptInserts := make([]RecipientInsert, 0, len(members)) @@ -429,7 +429,7 @@ func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid. SenderIP: senderIP, Subject: subject, Body: body, - BodyLang: LangUndetermined, + BodyLang: s.deps.Detector.Detect(body), BroadcastScope: scope, } switch callerKind { diff --git a/backend/internal/diplomail/deps.go b/backend/internal/diplomail/deps.go index 66c0a29..b132068 100644 --- a/backend/internal/diplomail/deps.go +++ b/backend/internal/diplomail/deps.go @@ -5,6 +5,8 @@ import ( "time" "galaxy/backend/internal/config" + "galaxy/backend/internal/diplomail/detector" + "galaxy/backend/internal/diplomail/translator" "github.com/google/uuid" "go.uber.org/zap" @@ -25,6 +27,8 @@ type Deps struct { Notification NotificationPublisher Entitlements EntitlementReader Games GameLookup + Detector detector.LanguageDetector + Translator translator.Translator Config config.DiplomailConfig Logger *zap.Logger Now func() time.Time diff --git a/backend/internal/diplomail/detector/detector.go b/backend/internal/diplomail/detector/detector.go new file mode 100644 index 0000000..e8717eb --- /dev/null +++ b/backend/internal/diplomail/detector/detector.go @@ -0,0 +1,79 @@ +// Package detector wraps the body-language detection used by the +// diplomail subsystem. The package exposes a narrow `LanguageDetector` +// interface so the implementation can be swapped without touching the +// callers; the default backed-by-whatlanggo detector handles 84 +// natural languages and ships with the embedded statistical profiles. +// +// Detection happens only on the body. Subjects are short and +// frequently template-like ("Re: ..."), so detecting on them adds +// noise. The diplomail Service feeds the body, captures the BCP 47 +// tag returned here, and stores it in `diplomail_messages.body_lang`. +package detector + +import ( + "strings" + "unicode/utf8" + + "github.com/abadojack/whatlanggo" +) + +// Undetermined is the BCP 47 placeholder stored when detection cannot +// confidently identify a language (empty body, too-short body, mixed +// scripts the detector refuses to bet on). +const Undetermined = "und" + +// LanguageDetector is the read-only surface diplomail consumes when +// it needs to label a message body. Detect must never panic and +// must never return an error: detection failure simply yields +// `Undetermined`. +type LanguageDetector interface { + Detect(body string) string +} + +// New returns the package-default detector backed by `whatlanggo`. +// The instance is safe for concurrent use; whatlanggo's `Detect` +// reads the embedded profiles without state mutation. Callers that +// want a fixed allow-list can build their own implementation around +// the same interface. +func New() LanguageDetector { + return &whatlangDetector{} +} + +type whatlangDetector struct{} + +// minRunes is the lower bound on body length below which whatlanggo +// can flip between near-synonyms; for shorter bodies we return +// `Undetermined` and let the noop translator skip the slot. The +// value matches whatlanggo's documented "stable above ~25 runes" +// guidance. +const minRunes = 25 + +// Detect returns the BCP 47 tag for body, or `Undetermined` when the +// body is empty / too short / whatlanggo refuses to label it. The +// trim is applied so leading whitespace does not bias the script +// detector toward Latin. We deliberately do not gate on +// `info.IsReliable()` because the gate is too conservative for the +// short sentences typical of in-game mail; a misclassification only +// hurts the translation cache key, never correctness. +func (d *whatlangDetector) Detect(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return Undetermined + } + if utf8.RuneCountInString(body) < minRunes { + return Undetermined + } + info := whatlanggo.Detect(body) + tag := info.Lang.Iso6391() + if tag == "" { + return Undetermined + } + return tag +} + +// NoopDetector returns the placeholder unconditionally. Used by +// tests and by Stage A code paths that predate the real detector. +type NoopDetector struct{} + +// Detect always returns `Undetermined` regardless of input. +func (NoopDetector) Detect(string) string { return Undetermined } diff --git a/backend/internal/diplomail/detector/detector_test.go b/backend/internal/diplomail/detector/detector_test.go new file mode 100644 index 0000000..f7f441c --- /dev/null +++ b/backend/internal/diplomail/detector/detector_test.go @@ -0,0 +1,49 @@ +package detector + +import "testing" + +func TestDetectKnownLanguages(t *testing.T) { + t.Parallel() + d := New() + cases := []struct { + name string + text string + want string + }{ + { + name: "english paragraph", + text: "The trade agreement should be signed before the next turn. " + + "I expect a written response by the time the engine generates the next report.", + want: "en", + }, + { + name: "russian paragraph", + text: "Привет! Я предлагаю заключить дипломатическое соглашение и провести " + + "совместную операцию по освоению гиперпространственных маршрутов. " + + "Жду твоего письменного ответа до конца следующего хода игры, " + + "чтобы мы успели согласовать детали и подписать договор вовремя.", + want: "ru", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := d.Detect(tc.text) + if got != tc.want { + t.Fatalf("Detect = %q, want %q", got, tc.want) + } + }) + } +} + +func TestDetectShortOrEmpty(t *testing.T) { + t.Parallel() + d := New() + short := []string{"", "hi", " "} + for _, s := range short { + if got := d.Detect(s); got != Undetermined { + t.Errorf("Detect(%q) = %q, want %q", s, got, Undetermined) + } + } +} diff --git a/backend/internal/diplomail/diplomail.go b/backend/internal/diplomail/diplomail.go index 4d350e1..c3655be 100644 --- a/backend/internal/diplomail/diplomail.go +++ b/backend/internal/diplomail/diplomail.go @@ -22,6 +22,8 @@ import ( "time" "galaxy/backend/internal/config" + "galaxy/backend/internal/diplomail/detector" + "galaxy/backend/internal/diplomail/translator" "go.uber.org/zap" ) @@ -91,6 +93,12 @@ func NewService(deps Deps) *Service { if deps.Notification == nil { deps.Notification = NewNoopNotificationPublisher(deps.Logger) } + if deps.Detector == nil { + deps.Detector = detector.NoopDetector{} + } + if deps.Translator == nil { + deps.Translator = translator.NewNoop() + } if deps.Config.MaxBodyBytes <= 0 { deps.Config.MaxBodyBytes = 4096 } diff --git a/backend/internal/diplomail/diplomail_e2e_test.go b/backend/internal/diplomail/diplomail_e2e_test.go index 250b71d..c02bf21 100644 --- a/backend/internal/diplomail/diplomail_e2e_test.go +++ b/backend/internal/diplomail/diplomail_e2e_test.go @@ -11,6 +11,7 @@ import ( "galaxy/backend/internal/config" "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/diplomail/translator" backendpg "galaxy/backend/internal/postgres" pgshared "galaxy/postgres" @@ -297,7 +298,7 @@ func TestDiplomailPersonalFlow(t *testing.T) { } // 2. ListInbox shows the message for the recipient. - inbox, err := svc.ListInbox(ctx, gameID, recipient) + inbox, err := svc.ListInbox(ctx, gameID, recipient, "") if err != nil { t.Fatalf("list inbox: %v", err) } @@ -315,7 +316,7 @@ func TestDiplomailPersonalFlow(t *testing.T) { } // 4. Non-recipient reads are 404. - if _, err := svc.GetMessage(ctx, other, msg.MessageID); !errors.Is(err, diplomail.ErrNotFound) { + if _, err := svc.GetMessage(ctx, other, msg.MessageID, ""); !errors.Is(err, diplomail.ErrNotFound) { t.Fatalf("non-recipient get: %v, want ErrNotFound", err) } @@ -359,7 +360,7 @@ func TestDiplomailPersonalFlow(t *testing.T) { } // 9. Inbox now excludes the soft-deleted message. - inbox, err = svc.ListInbox(ctx, gameID, recipient) + inbox, err = svc.ListInbox(ctx, gameID, recipient, "") if err != nil { t.Fatalf("list inbox after delete: %v", err) } @@ -501,7 +502,7 @@ func TestDiplomailAdminBroadcast(t *testing.T) { // that alice might have sent before the kick (none here — the // store path itself is exercised; the soft-access filter belongs // to a separate test below). - charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie) + charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie, "") if err != nil { t.Fatalf("kicked inbox: %v", err) } @@ -795,7 +796,7 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) { if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != kicked { t.Fatalf("publisher captured %+v, want one event addressed to kicked", got) } - inbox, err := svc.ListInbox(ctx, gameID, kicked) + inbox, err := svc.ListInbox(ctx, gameID, kicked, "") if err != nil { t.Fatalf("kicked inbox: %v", err) } @@ -807,6 +808,112 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) { } } +// staticTranslator returns deterministic renderings so the +// translation-cache test can assert against known output. +type staticTranslator struct { + engine string +} + +func (s *staticTranslator) Translate(_ context.Context, srcLang, dstLang, subject, body string) (translator.Result, error) { + return translator.Result{ + Subject: "[" + dstLang + "] " + subject, + Body: "[" + dstLang + "] " + body, + Engine: s.engine, + }, nil +} + +func TestDiplomailTranslationCache(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedGame(t, db, gameID, "Translation Test Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, sender}: { + UserID: sender, GameID: gameID, GameName: "Translation Test Game", + UserName: "sender", RaceName: "SendersRace", + }, + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Translation Test Game", + UserName: "recipient", RaceName: "ReceiversRace", + }, + }, + } + publisher := &recordingPublisher{} + + englishDetector := detectorFn(func(_ string) string { return "en" }) + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Detector: englishDetector, + Translator: &staticTranslator{engine: "stub"}, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + msg, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Subject: "Hello", + Body: "Please share the latest map snapshot.", + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if msg.BodyLang != "en" { + t.Fatalf("body_lang=%q, want en (detector returns en)", msg.BodyLang) + } + + // First read materialises a cached translation. + entry, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru") + if err != nil { + t.Fatalf("get message: %v", err) + } + if entry.Translation == nil { + t.Fatalf("translation missing on first read") + } + if entry.Translation.TargetLang != "ru" { + t.Fatalf("translation lang=%q, want ru", entry.Translation.TargetLang) + } + if entry.Translation.TranslatedBody != "[ru] Please share the latest map snapshot." { + t.Fatalf("translated body = %q", entry.Translation.TranslatedBody) + } + + // Second read returns the same cached row (no re-translation). + entry2, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru") + if err != nil { + t.Fatalf("get message twice: %v", err) + } + if entry2.Translation == nil || entry2.Translation.TranslationID != entry.Translation.TranslationID { + t.Fatalf("second read produced new translation: got %+v, want %s", entry2.Translation, entry.Translation.TranslationID) + } + + // Same language as body: no translation. + entrySame, err := svc.GetMessage(ctx, recipient, msg.MessageID, "en") + if err != nil { + t.Fatalf("get message same lang: %v", err) + } + if entrySame.Translation != nil { + t.Fatalf("translation populated when target == body_lang") + } +} + +// detectorFn lets the test inject deterministic detection without +// dragging the whatlanggo profile into the test fixtures. +type detectorFn func(string) string + +func (f detectorFn) Detect(s string) string { return f(s) } + func TestDiplomailRejectsOverlongBody(t *testing.T) { db := startPostgres(t) ctx := context.Background() diff --git a/backend/internal/diplomail/service.go b/backend/internal/diplomail/service.go index e0f18ef..71ac29d 100644 --- a/backend/internal/diplomail/service.go +++ b/backend/internal/diplomail/service.go @@ -68,7 +68,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa SenderIP: in.SenderIP, Subject: subject, Body: body, - BodyLang: LangUndetermined, + BodyLang: s.deps.Detector.Detect(body), BroadcastScope: BroadcastScopeSingle, } raceName := recipient.RaceName @@ -104,7 +104,15 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa // the caller is no longer an active member of the game and the // message is personal-kind: post-kick visibility is restricted to // admin/system mail (item 8 of the spec). -func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (InboxEntry, error) { +// +// When `targetLang` is non-empty and differs from the message's +// `body_lang`, the function consults the translation cache; on a +// miss it asks the configured Translator to produce a rendering and +// persists the result. The noop translator returns the input +// unchanged with `engine == "noop"`, which is treated as +// "translation unavailable" — the entry comes back with `Translation +// == nil` and the caller renders the original body. +func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID, targetLang string) (InboxEntry, error) { entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID) if err != nil { return InboxEntry{}, err @@ -116,9 +124,65 @@ func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) ( if !allowed[entry.Kind] { return InboxEntry{}, ErrNotFound } + if tr := s.resolveTranslation(ctx, entry.Message, targetLang); tr != nil { + entry.Translation = tr + } return entry, nil } +// resolveTranslation returns the cached translation for +// (message, targetLang), lazily computing and persisting one on +// cache miss. Returns nil when no translation is needed (target is +// empty, matches `body_lang`, or the message body is itself +// undetermined) or when the configured translator declares the +// rendering unavailable. +func (s *Service) resolveTranslation(ctx context.Context, msg Message, targetLang string) *Translation { + if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined { + return nil + } + if existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang); err == nil { + t := existing + return &t + } else if !errors.Is(err, ErrNotFound) { + s.deps.Logger.Warn("load translation failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("target_lang", targetLang), + zap.Error(err)) + return nil + } + if s.deps.Translator == nil { + return nil + } + result, err := s.deps.Translator.Translate(ctx, msg.BodyLang, targetLang, msg.Subject, msg.Body) + if err != nil { + s.deps.Logger.Warn("translator call failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("target_lang", targetLang), + zap.Error(err)) + return nil + } + if result.Engine == "" || result.Engine == "noop" { + return nil + } + tr := Translation{ + TranslationID: uuid.New(), + MessageID: msg.MessageID, + TargetLang: targetLang, + TranslatedSubject: result.Subject, + TranslatedBody: result.Body, + Translator: result.Engine, + } + stored, err := s.deps.Store.InsertTranslation(ctx, tr) + if err != nil { + s.deps.Logger.Warn("insert translation failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("target_lang", targetLang), + zap.Error(err)) + return nil + } + return &stored +} + // ListInbox returns every non-deleted message addressed to userID in // gameID, newest first. Read state is preserved per entry; the HTTP // layer renders both the message and the recipient row. Personal @@ -126,7 +190,12 @@ func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) ( // member of the game so a kicked player keeps read access to the // admin/system explanation of the kick but not to historical // player-to-player threads. -func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) { +// +// When `targetLang` is non-empty and differs from a row's body +// language, the function consults the translation cache (without +// re-translating on miss; the per-message read endpoint owns that +// path so the bulk listing never blocks on translator I/O). +func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID, targetLang string) ([]InboxEntry, error) { entries, err := s.deps.Store.ListInbox(ctx, gameID, userID) if err != nil { return nil, err @@ -135,18 +204,46 @@ func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]In if err != nil { return nil, err } - if allowed[KindPersonal] && allowed[KindAdmin] { - return entries, nil - } - out := make([]InboxEntry, 0, len(entries)) - for _, e := range entries { - if allowed[e.Kind] { - out = append(out, e) + out := entries + if !(allowed[KindPersonal] && allowed[KindAdmin]) { + out = make([]InboxEntry, 0, len(entries)) + for _, e := range entries { + if allowed[e.Kind] { + out = append(out, e) + } } } + if targetLang == "" { + return out, nil + } + for i := range out { + out[i].Translation = s.lookupCachedTranslation(ctx, out[i].Message, targetLang) + } return out, nil } +// lookupCachedTranslation reads an existing translation row without +// asking the Translator to compute one. The bulk inbox listing uses +// this to avoid per-row translator I/O; GetMessage uses the full +// `resolveTranslation` helper which falls through to the translator +// on cache miss. +func (s *Service) lookupCachedTranslation(ctx context.Context, msg Message, targetLang string) *Translation { + if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined { + return nil + } + existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang) + if err != nil { + if !errors.Is(err, ErrNotFound) { + s.deps.Logger.Debug("inbox translation lookup failed", + zap.String("message_id", msg.MessageID.String()), + zap.Error(err)) + } + return nil + } + out := existing + return &out +} + // allowedKinds resolves the set of message kinds the caller may read // in gameID. An active member can read everything; a former member // (status removed or blocked) can read admin-kind only. A user who diff --git a/backend/internal/diplomail/store.go b/backend/internal/diplomail/store.go index 73af93b..3fbdb1d 100644 --- a/backend/internal/diplomail/store.go +++ b/backend/internal/diplomail/store.go @@ -358,6 +358,71 @@ func (s *Store) UnreadCountForUserGame(ctx context.Context, gameID, userID uuid. return int(dest.Count), nil } +// translationColumns is the canonical projection for +// diplomail_translations reads. +func translationColumns() postgres.ColumnList { + t := table.DiplomailTranslations + return postgres.ColumnList{ + t.TranslationID, t.MessageID, t.TargetLang, + t.TranslatedSubject, t.TranslatedBody, t.Translator, t.TranslatedAt, + } +} + +// LoadTranslation returns the cached translation row for +// (messageID, targetLang). Returns ErrNotFound when no cache row +// exists yet — the caller decides whether to compute and persist +// one. +func (s *Store) LoadTranslation(ctx context.Context, messageID uuid.UUID, targetLang string) (Translation, error) { + t := table.DiplomailTranslations + stmt := postgres.SELECT(translationColumns()). + FROM(t). + WHERE(t.MessageID.EQ(postgres.UUID(messageID)). + AND(t.TargetLang.EQ(postgres.String(targetLang)))). + LIMIT(1) + var row model.DiplomailTranslations + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Translation{}, ErrNotFound + } + return Translation{}, fmt.Errorf("diplomail store: load translation %s/%s: %w", messageID, targetLang, err) + } + return translationFromModel(row), nil +} + +// InsertTranslation persists a new translation cache row. The unique +// constraint on (message_id, target_lang) prevents duplicate +// renderings. Callers that race on the same (message, lang) pair +// should be prepared for a UNIQUE violation; the second writer can +// fall back to LoadTranslation. +func (s *Store) InsertTranslation(ctx context.Context, in Translation) (Translation, error) { + t := table.DiplomailTranslations + stmt := t.INSERT( + t.TranslationID, t.MessageID, t.TargetLang, + t.TranslatedSubject, t.TranslatedBody, t.Translator, + ).VALUES( + in.TranslationID, in.MessageID, in.TargetLang, + in.TranslatedSubject, in.TranslatedBody, in.Translator, + ).RETURNING(translationColumns()) + + var row model.DiplomailTranslations + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Translation{}, fmt.Errorf("diplomail store: insert translation %s/%s: %w", in.MessageID, in.TargetLang, err) + } + return translationFromModel(row), nil +} + +func translationFromModel(row model.DiplomailTranslations) Translation { + return Translation{ + TranslationID: row.TranslationID, + MessageID: row.MessageID, + TargetLang: row.TargetLang, + TranslatedSubject: row.TranslatedSubject, + TranslatedBody: row.TranslatedBody, + Translator: row.Translator, + TranslatedAt: row.TranslatedAt, + } +} + // DeleteMessagesForGames removes every diplomail_messages row whose // game_id falls in the supplied set. The cascade defined on the // `diplomail_recipients` and `diplomail_translations` foreign keys diff --git a/backend/internal/diplomail/translator/translator.go b/backend/internal/diplomail/translator/translator.go new file mode 100644 index 0000000..5d0799d --- /dev/null +++ b/backend/internal/diplomail/translator/translator.go @@ -0,0 +1,59 @@ +// Package translator wraps the per-language rendering for the +// diplomail subsystem. The package exposes a narrow `Translator` +// interface so the actual translation backend (LibreTranslate, an +// in-process model, a SaaS engine, …) can be swapped without +// touching the rest of the codebase. +// +// Stage D ships a `NoopTranslator` that returns the input unchanged. +// The diplomail Service treats a `Name == NoopEngine` result as +// "translation unavailable" and refrains from writing a cache row; +// the inbox handler then returns the original body with a +// `translated == false` payload. The contract lets the rest of the +// system ship without a translation backend; future stages can wire +// a real `Translator` without code changes elsewhere. +package translator + +import "context" + +// NoopEngine is the engine identifier returned by `NoopTranslator`. +// The diplomail Service checks for this value to decide whether to +// persist a `diplomail_translations` row. +const NoopEngine = "noop" + +// Result carries one translated rendering plus the engine identifier +// that produced it. The engine name is persisted as +// `diplomail_translations.translator` so an operator can see which +// backend produced each row. +type Result struct { + Subject string + Body string + Engine string +} + +// Translator is the read-only surface diplomail consumes when it +// needs to render a message for a recipient whose +// `preferred_language` differs from `body_lang`. Implementations +// must be safe for concurrent use; `Translate` may be invoked from +// the async worker on many messages at once. +type Translator interface { + // Translate renders `subject` and `body` from `srcLang` into + // `dstLang`. A nil error with `Result.Engine == NoopEngine` + // signals that no real rendering happened. + Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error) +} + +// NewNoop returns a Translator that always returns the input +// unchanged with engine name `NoopEngine`. +func NewNoop() Translator { + return noop{} +} + +type noop struct{} + +func (noop) Translate(_ context.Context, _, _, subject, body string) (Result, error) { + return Result{ + Subject: subject, + Body: body, + Engine: NoopEngine, + }, nil +} diff --git a/backend/internal/diplomail/types.go b/backend/internal/diplomail/types.go index cfed844..1ff47dd 100644 --- a/backend/internal/diplomail/types.go +++ b/backend/internal/diplomail/types.go @@ -51,10 +51,28 @@ type Recipient struct { // InboxEntry is the read-side projection composed of a Message and the // caller's own Recipient row. The HTTP layer renders one of these per -// item in the inbox listing. +// item in the inbox listing. Translation, when non-nil, carries the +// per-recipient rendering returned from +// `Service.GetMessage(ctx, …, targetLang)` and surfaced under the +// `body_translated` payload field; Stage D ships a noop translator, +// so this field stays nil until a real backend is wired. type InboxEntry struct { Message - Recipient Recipient + Recipient Recipient + Translation *Translation +} + +// Translation mirrors a row in `backend.diplomail_translations`. The +// engine identifier is preserved so an operator can see which +// backend produced the cached rendering. +type Translation struct { + TranslationID uuid.UUID + MessageID uuid.UUID + TargetLang string + TranslatedSubject string + TranslatedBody string + Translator string + TranslatedAt time.Time } // SendPersonalInput is the request payload for SendPersonal: the diff --git a/backend/internal/server/handlers_user_mail.go b/backend/internal/server/handlers_user_mail.go index 58a8bf3..53960b6 100644 --- a/backend/internal/server/handlers_user_mail.go +++ b/backend/internal/server/handlers_user_mail.go @@ -48,6 +48,25 @@ func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users } } +// preferredLanguage looks up the caller's `accounts.preferred_language` +// so the per-message read can attach the cached translation when +// available. Failures are logged at debug level and the function +// returns an empty string — translation is best-effort and the +// caller still receives the original body. +func (h *UserMailHandlers) preferredLanguage(ctx context.Context, userID uuid.UUID) string { + if h.users == nil { + return "" + } + account, err := h.users.GetAccount(ctx, userID) + if err != nil { + h.logger.Debug("resolve preferred_language failed", + zap.String("user_id", userID.String()), + zap.Error(err)) + return "" + } + return account.PreferredLanguage +} + // SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages. func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc { if h.svc == nil { @@ -109,7 +128,8 @@ func (h *UserMailHandlers) Get() gin.HandlerFunc { return } ctx := c.Request.Context() - entry, err := h.svc.GetMessage(ctx, userID, messageID) + targetLang := h.preferredLanguage(ctx, userID) + entry, err := h.svc.GetMessage(ctx, userID, messageID, targetLang) if err != nil { respondDiplomailError(c, h.logger, "user mail get", ctx, err) return @@ -134,7 +154,8 @@ func (h *UserMailHandlers) Inbox() gin.HandlerFunc { return } ctx := c.Request.Context() - items, err := h.svc.ListInbox(ctx, gameID, userID) + targetLang := h.preferredLanguage(ctx, userID) + items, err := h.svc.ListInbox(ctx, gameID, userID, targetLang) if err != nil { respondDiplomailError(c, h.logger, "user mail inbox", ctx, err) return @@ -491,6 +512,10 @@ func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Reci // userMailMessageDetailWire mirrors the unified response shape for // inbox listings and per-message reads. Sender identifiers are // optional: system messages carry neither user id nor username. +// Translation fields are populated when a cached rendering exists +// for the caller's `preferred_language`; the UI renders +// `body_translated` and surfaces the original through a +// "show original" toggle. type userMailMessageDetailWire struct { MessageID string `json:"message_id"` GameID string `json:"game_id"` @@ -509,6 +534,10 @@ type userMailMessageDetailWire struct { RecipientRaceName *string `json:"recipient_race_name,omitempty"` ReadAt *string `json:"read_at,omitempty"` DeletedAt *string `json:"deleted_at,omitempty"` + TranslatedSubject *string `json:"translated_subject,omitempty"` + TranslatedBody *string `json:"translated_body,omitempty"` + TranslationLang *string `json:"translation_lang,omitempty"` + Translator *string `json:"translator,omitempty"` } // userMailSentSummaryWire mirrors the response shape for the @@ -580,6 +609,17 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM s := entry.Recipient.DeletedAt.UTC().Format(timestampLayout) out.DeletedAt = &s } + if entry.Translation != nil { + tr := entry.Translation + subj := tr.TranslatedSubject + body := tr.TranslatedBody + lang := tr.TargetLang + engine := tr.Translator + out.TranslatedSubject = &subj + out.TranslatedBody = &body + out.TranslationLang = &lang + out.Translator = &engine + } _ = justCreated return out } diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 3cd04a0..251fb6d 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -4352,6 +4352,24 @@ components: type: string format: date-time nullable: true + translated_subject: + type: string + description: | + Subject rendered into the caller's preferred_language by + the translation cache. Absent when the caller's language + matches `body_lang` or the translator could not produce + a rendering. + translated_body: + type: string + description: | + Body rendered into the caller's preferred_language. Same + absence semantics as `translated_subject`. + translation_lang: + type: string + description: BCP 47 tag of the rendered translation. + translator: + type: string + description: Identifier of the translation engine that produced the cached row. UserMailSentSummary: type: object additionalProperties: false