diplomail (Stage E): LibreTranslate client + async translation worker
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · Integration / integration (pull_request) Successful in 1m37s

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:
Ilia Denisov
2026-05-15 20:15:28 +02:00
parent e22f4b7800
commit 9f7c9099bc
16 changed files with 1222 additions and 155 deletions
+17 -13
View File
@@ -72,18 +72,20 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
BroadcastScope: BroadcastScopeSingle,
}
raceName := recipient.RaceName
var raceNamePtr *string
if raceName != "" {
raceNamePtr = &raceName
}
rcptInsert := RecipientInsert{
RecipientID: uuid.New(),
MessageID: msgInsert.MessageID,
GameID: in.GameID,
UserID: in.RecipientUserID,
RecipientUserName: recipient.UserName,
RecipientRaceName: raceNamePtr,
}
rcptInsert := buildRecipientInsert(
msgInsert.MessageID,
MemberSnapshot{
UserID: in.RecipientUserID,
GameID: in.GameID,
GameName: recipient.GameName,
UserName: recipient.UserName,
RaceName: raceName,
PreferredLanguage: recipient.PreferredLanguage,
Status: "active",
},
msgInsert.BodyLang,
s.nowUTC(),
)
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
if err != nil {
@@ -93,7 +95,9 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: unexpected recipient count %d", len(recipients))
}
s.publishMessageReceived(ctx, msg, recipients[0])
if recipients[0].AvailableAt != nil {
s.publishMessageReceived(ctx, msg, recipients[0])
}
return msg, recipients[0], nil
}