7b43ce5844
Phase 28's in-game mail UI groups personal threads by the other party's race. To support that without an extra membership-listing RPC, the diplomail subsystem now: - accepts `recipient_race_name` on `POST /messages` and `POST /admin` (target=user) as an alternative to `recipient_user_id`; the service resolves it via the existing `Memberships.ListMembers(gameID, "active")` and rejects with `forbidden` when the matching member is no longer active; - snapshots `diplomail_messages.sender_race_name` at send time for every player sender (admin / system rows stay NULL). The UI keys per-race threading on this column. Schema, openapi, README, and a focused e2e test for the new path (happy path + dual / missing / unknown / kicked errors) land in this commit; the gateway + UI legs follow in subsequent commits on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
635 lines
23 KiB
Go
635 lines
23 KiB
Go
package diplomail
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// SendAdminPersonal persists an admin-kind message addressed to a
|
|
// single recipient and fan-outs the push event. The HTTP layer is
|
|
// responsible for the owner-vs-admin authorisation decision; this
|
|
// function trusts the caller designation it receives.
|
|
//
|
|
// The recipient may be in any membership status, so the lookup goes
|
|
// through MembershipLookup.GetMembershipAnyStatus. This lets the
|
|
// owner / admin reach a kicked player to explain the kick or follow
|
|
// up after a removal.
|
|
func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInput) (Message, Recipient, error) {
|
|
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
|
if err != nil {
|
|
return Message{}, Recipient{}, err
|
|
}
|
|
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
|
|
return Message{}, Recipient{}, err
|
|
}
|
|
|
|
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
|
|
if err != nil {
|
|
return Message{}, Recipient{}, err
|
|
}
|
|
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, recipientID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
|
|
}
|
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
|
|
}
|
|
|
|
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
|
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
|
|
if err != nil {
|
|
return Message{}, Recipient{}, err
|
|
}
|
|
rcptInsert := buildRecipientInsert(msgInsert.MessageID, recipient, msgInsert.BodyLang, s.nowUTC())
|
|
|
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
|
if err != nil {
|
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: %w", err)
|
|
}
|
|
if len(recipients) != 1 {
|
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: unexpected recipient count %d", len(recipients))
|
|
}
|
|
|
|
if recipients[0].AvailableAt != nil { s.publishMessageReceived(ctx, msg, recipients[0]) }
|
|
return msg, recipients[0], nil
|
|
}
|
|
|
|
// SendAdminBroadcast persists an admin-kind broadcast addressed to
|
|
// every member matching `RecipientScope`, then emits one push event
|
|
// per recipient. The caller's own membership row, when present, is
|
|
// excluded from the recipient list — broadcasters do not get a copy
|
|
// of their own message.
|
|
func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastInput) (Message, []Recipient, error) {
|
|
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
|
if err != nil {
|
|
return Message{}, nil, err
|
|
}
|
|
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
|
|
return Message{}, nil, err
|
|
}
|
|
scope, err := normaliseScope(in.RecipientScope)
|
|
if err != nil {
|
|
return Message{}, nil, err
|
|
}
|
|
|
|
members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, scope)
|
|
if err != nil {
|
|
return Message{}, nil, fmt.Errorf("diplomail: list members for broadcast: %w", err)
|
|
}
|
|
members = filterOutCaller(members, in.CallerUserID)
|
|
if len(members) == 0 {
|
|
return Message{}, nil, fmt.Errorf("%w: no recipients for broadcast", ErrInvalidInput)
|
|
}
|
|
|
|
gameName := members[0].GameName
|
|
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
|
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
|
|
if err != nil {
|
|
return Message{}, nil, err
|
|
}
|
|
rcptInserts := make([]RecipientInsert, 0, len(members))
|
|
for _, m := range members {
|
|
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
|
}
|
|
|
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
|
if err != nil {
|
|
return Message{}, nil, fmt.Errorf("diplomail: send admin broadcast: %w", err)
|
|
}
|
|
for _, r := range recipients {
|
|
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
|
}
|
|
return msg, recipients, nil
|
|
}
|
|
|
|
// SendPlayerBroadcast persists a paid-tier player broadcast and
|
|
// fans out the push event to every other active member of the game.
|
|
// The send is `kind="personal"`, `sender_kind="player"`,
|
|
// `broadcast_scope="game_broadcast"` — recipients reply to it as if
|
|
// it were a single-recipient personal send, and the reply targets
|
|
// only the broadcaster. The caller's entitlement tier is checked
|
|
// against `EntitlementReader`; free-tier callers are rejected with
|
|
// ErrForbidden.
|
|
func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcastInput) (Message, []Recipient, error) {
|
|
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
|
if err != nil {
|
|
return Message{}, nil, err
|
|
}
|
|
if s.deps.Entitlements == nil {
|
|
return Message{}, nil, fmt.Errorf("%w: entitlement reader is not wired", ErrForbidden)
|
|
}
|
|
paid, err := s.deps.Entitlements.IsPaidTier(ctx, in.SenderUserID)
|
|
if err != nil {
|
|
return Message{}, nil, fmt.Errorf("diplomail: entitlement lookup: %w", err)
|
|
}
|
|
if !paid {
|
|
return Message{}, nil, fmt.Errorf("%w: in-game broadcast requires a paid tier", ErrForbidden)
|
|
}
|
|
|
|
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return Message{}, nil, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
|
|
}
|
|
return Message{}, nil, fmt.Errorf("diplomail: load sender membership: %w", err)
|
|
}
|
|
|
|
members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, RecipientScopeActive)
|
|
if err != nil {
|
|
return Message{}, nil, fmt.Errorf("diplomail: list active members: %w", err)
|
|
}
|
|
callerID := in.SenderUserID
|
|
members = filterOutCaller(members, &callerID)
|
|
if len(members) == 0 {
|
|
return Message{}, nil, fmt.Errorf("%w: no other active members in this game", ErrInvalidInput)
|
|
}
|
|
|
|
username := sender.UserName
|
|
senderRace := sender.RaceName
|
|
msgInsert := MessageInsert{
|
|
MessageID: uuid.New(),
|
|
GameID: in.GameID,
|
|
GameName: sender.GameName,
|
|
Kind: KindPersonal,
|
|
SenderKind: SenderKindPlayer,
|
|
SenderUserID: &callerID,
|
|
SenderUsername: &username,
|
|
SenderRaceName: &senderRace,
|
|
SenderIP: in.SenderIP,
|
|
Subject: subject,
|
|
Body: body,
|
|
BodyLang: s.deps.Detector.Detect(body),
|
|
BroadcastScope: BroadcastScopeGameBroadcast,
|
|
}
|
|
rcptInserts := make([]RecipientInsert, 0, len(members))
|
|
for _, m := range members {
|
|
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
|
}
|
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
|
if err != nil {
|
|
return Message{}, nil, fmt.Errorf("diplomail: send player broadcast: %w", err)
|
|
}
|
|
for _, r := range recipients {
|
|
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
|
}
|
|
return msg, recipients, nil
|
|
}
|
|
|
|
// SendAdminMultiGameBroadcast emits one admin-kind message per game
|
|
// resolved from the input scope and fans out the push events. A
|
|
// recipient who plays in multiple addressed games receives one
|
|
// independently-deletable inbox entry per game; this avoids cross-
|
|
// game leakage of admin context and keeps the per-game unread badge
|
|
// honest.
|
|
func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiGameBroadcastInput) ([]Message, int, error) {
|
|
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err := validateCaller(CallerKindAdmin, nil, in.CallerUsername); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
scope, err := normaliseScope(in.RecipientScope)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if s.deps.Games == nil {
|
|
return nil, 0, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
|
|
}
|
|
games, err := s.resolveMultiGameTargets(ctx, in)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if len(games) == 0 {
|
|
return nil, 0, fmt.Errorf("%w: no games match the broadcast scope", ErrInvalidInput)
|
|
}
|
|
|
|
totalRecipients := 0
|
|
out := make([]Message, 0, len(games))
|
|
for _, game := range games {
|
|
members, err := s.deps.Memberships.ListMembers(ctx, game.GameID, scope)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("diplomail: list members for %s: %w", game.GameID, err)
|
|
}
|
|
if len(members) == 0 {
|
|
s.deps.Logger.Debug("multi-game broadcast skips empty game",
|
|
zap.String("game_id", game.GameID.String()),
|
|
zap.String("scope", scope))
|
|
continue
|
|
}
|
|
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindAdmin, nil, in.CallerUsername,
|
|
game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rcptInserts := make([]RecipientInsert, 0, len(members))
|
|
for _, m := range members {
|
|
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
|
}
|
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("diplomail: insert multi-game broadcast for %s: %w", game.GameID, err)
|
|
}
|
|
for _, r := range recipients {
|
|
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
|
}
|
|
out = append(out, msg)
|
|
totalRecipients += len(recipients)
|
|
}
|
|
return out, totalRecipients, nil
|
|
}
|
|
|
|
func (s *Service) resolveMultiGameTargets(ctx context.Context, in SendMultiGameBroadcastInput) ([]GameSnapshot, error) {
|
|
switch in.Scope {
|
|
case MultiGameScopeAllRunning:
|
|
games, err := s.deps.Games.ListRunningGames(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("diplomail: list running games: %w", err)
|
|
}
|
|
return games, nil
|
|
case MultiGameScopeSelected, "":
|
|
if len(in.GameIDs) == 0 {
|
|
return nil, fmt.Errorf("%w: selected scope requires game_ids", ErrInvalidInput)
|
|
}
|
|
out := make([]GameSnapshot, 0, len(in.GameIDs))
|
|
for _, id := range in.GameIDs {
|
|
game, err := s.deps.Games.GetGame(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: game %s not found", ErrInvalidInput, id)
|
|
}
|
|
return nil, fmt.Errorf("diplomail: load game %s: %w", id, err)
|
|
}
|
|
out = append(out, game)
|
|
}
|
|
return out, nil
|
|
default:
|
|
return nil, fmt.Errorf("%w: unknown multi-game scope %q", ErrInvalidInput, in.Scope)
|
|
}
|
|
}
|
|
|
|
// BulkCleanup deletes every diplomail_messages row tied to games that
|
|
// finished more than `OlderThanYears` years ago. Returns the affected
|
|
// game ids and the count of removed messages. The minimum allowed
|
|
// value is 1 year — finer-grained pruning would risk wiping live
|
|
// arbitration evidence.
|
|
func (s *Service) BulkCleanup(ctx context.Context, in BulkCleanupInput) (CleanupResult, error) {
|
|
if in.OlderThanYears < 1 {
|
|
return CleanupResult{}, fmt.Errorf("%w: older_than_years must be >= 1", ErrInvalidInput)
|
|
}
|
|
if s.deps.Games == nil {
|
|
return CleanupResult{}, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
|
|
}
|
|
cutoff := s.nowUTC().AddDate(-in.OlderThanYears, 0, 0)
|
|
games, err := s.deps.Games.ListFinishedGamesBefore(ctx, cutoff)
|
|
if err != nil {
|
|
return CleanupResult{}, fmt.Errorf("diplomail: list finished games: %w", err)
|
|
}
|
|
if len(games) == 0 {
|
|
return CleanupResult{}, nil
|
|
}
|
|
gameIDs := make([]uuid.UUID, 0, len(games))
|
|
for _, g := range games {
|
|
gameIDs = append(gameIDs, g.GameID)
|
|
}
|
|
deleted, err := s.deps.Store.DeleteMessagesForGames(ctx, gameIDs)
|
|
if err != nil {
|
|
return CleanupResult{}, fmt.Errorf("diplomail: bulk delete: %w", err)
|
|
}
|
|
return CleanupResult{GameIDs: gameIDs, MessagesDeleted: deleted}, nil
|
|
}
|
|
|
|
// ListMessagesForAdmin returns a paginated, optionally-filtered view
|
|
// of every persisted message. Used by the admin observability
|
|
// endpoint to inspect what has been sent and trace abuse reports.
|
|
func (s *Service) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) (AdminMessagePage, error) {
|
|
rows, total, err := s.deps.Store.ListMessagesForAdmin(ctx, filter)
|
|
if err != nil {
|
|
return AdminMessagePage{}, err
|
|
}
|
|
page := filter.Page
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
pageSize := filter.PageSize
|
|
if pageSize < 1 {
|
|
pageSize = 50
|
|
}
|
|
return AdminMessagePage{
|
|
Items: rows,
|
|
Total: total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// PublishLifecycle persists a system-kind message in response to a
|
|
// lobby lifecycle transition and fan-outs push events to the
|
|
// affected recipients. Game-scoped transitions (`game.paused`,
|
|
// `game.cancelled`) reach every active member; membership-scoped
|
|
// transitions (`membership.removed`, `membership.blocked`) reach the
|
|
// kicked player only. Failures inside the function are logged at
|
|
// Warn level — lifecycle hooks must not block the lobby state
|
|
// machine on a downstream mail failure.
|
|
func (s *Service) PublishLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
|
switch ev.Kind {
|
|
case LifecycleKindGamePaused, LifecycleKindGameCancelled:
|
|
return s.publishGameLifecycle(ctx, ev)
|
|
case LifecycleKindMembershipRemoved, LifecycleKindMembershipBlocked:
|
|
return s.publishMembershipLifecycle(ctx, ev)
|
|
default:
|
|
return fmt.Errorf("%w: unknown lifecycle kind %q", ErrInvalidInput, ev.Kind)
|
|
}
|
|
}
|
|
|
|
func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
|
members, err := s.deps.Memberships.ListMembers(ctx, ev.GameID, RecipientScopeActive)
|
|
if err != nil {
|
|
return fmt.Errorf("diplomail lifecycle: list members for %s: %w", ev.GameID, err)
|
|
}
|
|
if len(members) == 0 {
|
|
s.deps.Logger.Debug("lifecycle skip: no active members",
|
|
zap.String("game_id", ev.GameID.String()),
|
|
zap.String("kind", ev.Kind))
|
|
return nil
|
|
}
|
|
gameName := members[0].GameName
|
|
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
|
|
|
|
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
|
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rcptInserts := make([]RecipientInsert, 0, len(members))
|
|
for _, m := range members {
|
|
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
|
}
|
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
|
if err != nil {
|
|
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
|
|
}
|
|
for _, r := range recipients {
|
|
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
|
if ev.TargetUser == nil {
|
|
return fmt.Errorf("%w: membership lifecycle requires TargetUser", ErrInvalidInput)
|
|
}
|
|
target, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, ev.GameID, *ev.TargetUser)
|
|
if err != nil {
|
|
return fmt.Errorf("diplomail lifecycle: load target membership: %w", err)
|
|
}
|
|
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
|
|
|
|
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
|
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rcptInsert := buildRecipientInsert(msgInsert.MessageID, target, msgInsert.BodyLang, s.nowUTC())
|
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
|
if err != nil {
|
|
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
|
|
}
|
|
if len(recipients) == 1 && recipients[0].AvailableAt != nil {
|
|
s.publishMessageReceived(ctx, msg, recipients[0])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// prepareContent normalises subject and body the same way SendPersonal
|
|
// does. Factored out so admin and lifecycle paths share the
|
|
// length-and-utf8 validation rules.
|
|
func (s *Service) prepareContent(subject, body string) (string, string, error) {
|
|
subj := strings.TrimRight(subject, " \t")
|
|
bod := strings.TrimRight(body, " \t\n")
|
|
if err := s.validateContent(subj, bod); err != nil {
|
|
return "", "", err
|
|
}
|
|
return subj, bod, nil
|
|
}
|
|
|
|
// buildAdminMessageInsert encapsulates the message-row construction
|
|
// for every admin-kind send. The CHECK constraint maps sender
|
|
// shapes:
|
|
//
|
|
// sender_kind='player' → CallerKind owner; sender_user_id set,
|
|
// sender_race_name resolved from
|
|
// Memberships.GetActiveMembership
|
|
// sender_kind='admin' → CallerKind admin; sender_user_id nil
|
|
// sender_kind='system' → CallerKind system; sender_username nil
|
|
func (s *Service) buildAdminMessageInsert(ctx context.Context, callerKind string, callerUserID *uuid.UUID, callerUsername string,
|
|
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
|
|
out := MessageInsert{
|
|
MessageID: uuid.New(),
|
|
GameID: gameID,
|
|
GameName: gameName,
|
|
Kind: KindAdmin,
|
|
SenderIP: senderIP,
|
|
Subject: subject,
|
|
Body: body,
|
|
BodyLang: s.deps.Detector.Detect(body),
|
|
BroadcastScope: scope,
|
|
}
|
|
switch callerKind {
|
|
case CallerKindOwner:
|
|
if callerUserID == nil {
|
|
return MessageInsert{}, fmt.Errorf("%w: owner send requires caller user id", ErrInvalidInput)
|
|
}
|
|
uid := *callerUserID
|
|
uname := callerUsername
|
|
out.SenderKind = SenderKindPlayer
|
|
out.SenderUserID = &uid
|
|
out.SenderUsername = &uname
|
|
// Owner race snapshot is best-effort: a private-game owner who
|
|
// has an active membership in their own game contributes a
|
|
// race name; an owner who is not a current member (or whose
|
|
// membership is removed/blocked) leaves the field nil. The
|
|
// CHECK constraint accepts both shapes for sender_kind='player'.
|
|
if ownerMember, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, uid); err == nil {
|
|
race := ownerMember.RaceName
|
|
out.SenderRaceName = &race
|
|
} else if !errors.Is(err, ErrNotFound) {
|
|
return MessageInsert{}, fmt.Errorf("diplomail: load owner membership: %w", err)
|
|
}
|
|
case CallerKindAdmin:
|
|
uname := callerUsername
|
|
out.SenderKind = SenderKindAdmin
|
|
out.SenderUsername = &uname
|
|
case CallerKindSystem:
|
|
out.SenderKind = SenderKindSystem
|
|
default:
|
|
return MessageInsert{}, fmt.Errorf("%w: unknown caller kind %q", ErrInvalidInput, callerKind)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// buildRecipientInsert turns a MemberSnapshot into a RecipientInsert.
|
|
// The race-name snapshot is nullable so a kicked player with no race
|
|
// name on file is still addressable.
|
|
//
|
|
// `bodyLang` is the detected language of the message body. When the
|
|
// recipient's preferred_language matches body_lang (or body_lang is
|
|
// undetermined), the function fills AvailableAt with `now` so the
|
|
// recipient row is materialised already-delivered; otherwise
|
|
// AvailableAt stays nil and the translation worker takes over.
|
|
func buildRecipientInsert(messageID uuid.UUID, m MemberSnapshot, bodyLang string, now time.Time) RecipientInsert {
|
|
in := RecipientInsert{
|
|
RecipientID: uuid.New(),
|
|
MessageID: messageID,
|
|
GameID: m.GameID,
|
|
UserID: m.UserID,
|
|
RecipientUserName: m.UserName,
|
|
RecipientPreferredLanguage: normaliseLang(m.PreferredLanguage),
|
|
}
|
|
if m.RaceName != "" {
|
|
race := m.RaceName
|
|
in.RecipientRaceName = &race
|
|
}
|
|
if needsTranslation(bodyLang, in.RecipientPreferredLanguage) {
|
|
// AvailableAt left nil → worker will deliver after the
|
|
// translation cache is materialised (or after fallback).
|
|
} else {
|
|
t := now.UTC()
|
|
in.AvailableAt = &t
|
|
}
|
|
return in
|
|
}
|
|
|
|
// needsTranslation reports whether a recipient with preferredLang
|
|
// needs to wait for a translated rendering before the message is
|
|
// considered delivered. Undetermined body language and empty
|
|
// recipient preferences are short-circuited to "no translation
|
|
// needed" so we never block delivery on something the detector
|
|
// could not label.
|
|
func needsTranslation(bodyLang, preferredLang string) bool {
|
|
bodyLang = normaliseLang(bodyLang)
|
|
preferredLang = normaliseLang(preferredLang)
|
|
if bodyLang == "" || bodyLang == LangUndetermined {
|
|
return false
|
|
}
|
|
if preferredLang == "" || preferredLang == LangUndetermined {
|
|
return false
|
|
}
|
|
return bodyLang != preferredLang
|
|
}
|
|
|
|
// normaliseLang strips any region subtag and lowercases the result so
|
|
// `en-US` and `EN` both collapse to `en`. The diplomail layer uses
|
|
// ISO 639-1 codes; whatlanggo and LibreTranslate share that
|
|
// vocabulary.
|
|
func normaliseLang(tag string) string {
|
|
tag = strings.TrimSpace(tag)
|
|
if tag == "" {
|
|
return ""
|
|
}
|
|
if i := strings.IndexAny(tag, "-_"); i > 0 {
|
|
tag = tag[:i]
|
|
}
|
|
return strings.ToLower(tag)
|
|
}
|
|
|
|
func validateCaller(callerKind string, callerUserID *uuid.UUID, callerUsername string) error {
|
|
switch callerKind {
|
|
case CallerKindOwner:
|
|
if callerUserID == nil {
|
|
return fmt.Errorf("%w: owner send requires caller_user_id", ErrInvalidInput)
|
|
}
|
|
if callerUsername == "" {
|
|
return fmt.Errorf("%w: owner send requires caller_username", ErrInvalidInput)
|
|
}
|
|
case CallerKindAdmin:
|
|
if callerUsername == "" {
|
|
return fmt.Errorf("%w: admin send requires caller_username", ErrInvalidInput)
|
|
}
|
|
case CallerKindSystem:
|
|
// no extra checks
|
|
default:
|
|
return fmt.Errorf("%w: unknown caller_kind %q", ErrInvalidInput, callerKind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normaliseScope(scope string) (string, error) {
|
|
switch scope {
|
|
case "", RecipientScopeActive:
|
|
return RecipientScopeActive, nil
|
|
case RecipientScopeActiveAndRemoved, RecipientScopeAllMembers:
|
|
return scope, nil
|
|
default:
|
|
return "", fmt.Errorf("%w: unknown recipient scope %q", ErrInvalidInput, scope)
|
|
}
|
|
}
|
|
|
|
func filterOutCaller(members []MemberSnapshot, callerUserID *uuid.UUID) []MemberSnapshot {
|
|
if callerUserID == nil {
|
|
return members
|
|
}
|
|
out := make([]MemberSnapshot, 0, len(members))
|
|
for _, m := range members {
|
|
if m.UserID == *callerUserID {
|
|
continue
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderGameLifecycle returns the (subject, body) pair persisted for
|
|
// the `game.paused` / `game.cancelled` system message. Bodies are in
|
|
// English; Stage D will translate them on demand into each
|
|
// recipient's preferred_language and cache the result.
|
|
func renderGameLifecycle(kind, gameName, actor, reason string) (string, string) {
|
|
actor = strings.TrimSpace(actor)
|
|
if actor == "" {
|
|
actor = "the system"
|
|
}
|
|
reasonTail := ""
|
|
if r := strings.TrimSpace(reason); r != "" {
|
|
reasonTail = " Reason: " + r + "."
|
|
}
|
|
switch kind {
|
|
case LifecycleKindGamePaused:
|
|
return "Game paused",
|
|
fmt.Sprintf("The game %q has been paused by %s.%s", gameName, actor, reasonTail)
|
|
case LifecycleKindGameCancelled:
|
|
return "Game cancelled",
|
|
fmt.Sprintf("The game %q has been cancelled by %s.%s", gameName, actor, reasonTail)
|
|
}
|
|
return "Game lifecycle update",
|
|
fmt.Sprintf("The game %q has changed state.%s", gameName, reasonTail)
|
|
}
|
|
|
|
// renderMembershipLifecycle returns the (subject, body) pair persisted
|
|
// for the `membership.removed` / `membership.blocked` system message.
|
|
func renderMembershipLifecycle(kind, gameName, actor, reason string) (string, string) {
|
|
actor = strings.TrimSpace(actor)
|
|
if actor == "" {
|
|
actor = "the system"
|
|
}
|
|
reasonTail := ""
|
|
if r := strings.TrimSpace(reason); r != "" {
|
|
reasonTail = " Reason: " + r + "."
|
|
}
|
|
switch kind {
|
|
case LifecycleKindMembershipRemoved:
|
|
return "Membership removed",
|
|
fmt.Sprintf("Your membership in %q has been removed by %s.%s", gameName, actor, reasonTail)
|
|
case LifecycleKindMembershipBlocked:
|
|
return "Membership blocked",
|
|
fmt.Sprintf("Your membership in %q has been blocked by %s.%s", gameName, actor, reasonTail)
|
|
}
|
|
return "Membership update",
|
|
fmt.Sprintf("Your membership in %q has changed.%s", gameName, reasonTail)
|
|
}
|