Files
scrabble-game/backend/internal/social/chat.go
T
Ilia Denisov 20f2a5a011
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
feat(chat): a message to a disguised robot opponent is born read
A pooled robot substituted into an ordinary (non-AI) game never opens the
chat, so a text message to it would linger unread forever — skewing the unread
count and the publish-to-read metric. Clear its recipient bit at PostMessage
time (robotRecipients via account.IsRobot), so the message is born read. The
human sender never had their own message unread, so this is invisible to them;
a nudge to a robot already self-clears when the robot answers by moving.
2026-06-17 11:46:59 +02:00

401 lines
13 KiB
Go

package social
import (
"context"
"errors"
"fmt"
"net/netip"
"slices"
"strings"
"time"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
const (
// maxChatRunes caps a chat message's length, keeping it to a quick reaction.
maxChatRunes = 60
// nudgeInterval is the minimum gap between two nudges by the same player in a game.
nudgeInterval = time.Hour
// kindMessage and kindNudge are the chat_messages.kind values.
kindMessage = "message"
kindNudge = "nudge"
// statusActive mirrors game.StatusActive: the status string a live game reports.
statusActive = "active"
)
// Message is one persisted per-game chat entry. A nudge is a Message with Kind
// nudge and an empty Body. SenderIP is the gateway-forwarded client IP (empty when
// unknown), kept for moderation.
type Message struct {
ID uuid.UUID
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP string
CreatedAt time.Time
}
// PostMessage stores a chat message from senderID in gameID. The sender must be a
// seated player who has not disabled chat; the body must be non-empty, within the
// rune limit, and free of links/emails/phone numbers (the content filter). The
// gateway-forwarded senderIP is validated and stored for moderation.
func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) {
seats, toMove, status, err := svc.games.Participants(ctx, gameID)
if err != nil {
return Message{}, err
}
idx := slices.Index(seats, senderID)
if idx < 0 {
return Message{}, ErrNotParticipant
}
// Chat is allowed only on the sender's own turn in an active game; the opponent's-turn
// control is the nudge.
if status != statusActive {
return Message{}, ErrGameNotActive
}
// Chat is disabled in honest-AI games (the opponent is a robot).
if vsAI, err := svc.games.VsAI(ctx, gameID); err != nil {
return Message{}, err
} else if vsAI {
return Message{}, ErrGameVsAI
}
if idx != toMove {
return Message{}, ErrChatNotYourTurn
}
// At most one chat message per turn. The turn's start (move-driven, so stable for the
// whole turn) bounds the window: a prior message posted at or after it closes chat until
// the next turn.
turnStart, err := svc.games.TurnStartedAt(ctx, gameID)
if err != nil {
return Message{}, err
}
if last, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil {
return Message{}, err
} else if ok && !last.Before(turnStart) {
return Message{}, ErrChatAlreadySentThisTurn
}
sender, err := svc.accounts.GetByID(ctx, senderID)
if err != nil {
return Message{}, err
}
if sender.BlockChat {
return Message{}, ErrChatBlocked
}
body = strings.TrimSpace(body)
if body == "" {
return Message{}, ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxChatRunes {
return Message{}, ErrMessageTooLong
}
if err := Clean(body); err != nil {
return Message{}, err
}
// A disguised robot opponent never reads chat, so its recipient bit is born clear.
autoRead, err := svc.robotRecipients(ctx, seats, senderID)
if err != nil {
return Message{}, err
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, autoRead))
if err != nil {
return Message{}, err
}
svc.metrics.recordChat(ctx, kindMessage)
svc.emitChat(seats, senderID, msg)
return msg, nil
}
// Nudge records a nudge from senderID toward the player whose turn is awaited. The
// game must be active, the sender a seated player whose turn it is not, and the
// once-per-hour-per-game limit not yet hit.
func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Message, error) {
seats, toMove, status, err := svc.games.Participants(ctx, gameID)
if err != nil {
return Message{}, err
}
if status != statusActive {
return Message{}, ErrGameNotActive
}
idx := slices.Index(seats, senderID)
if idx < 0 {
return Message{}, ErrNotParticipant
}
// Nudge is disabled in honest-AI games (the opponent is a robot).
if vsAI, err := svc.games.VsAI(ctx, gameID); err != nil {
return Message{}, err
} else if vsAI {
return Message{}, ErrGameVsAI
}
if idx == toMove {
return Message{}, ErrNudgeOnOwnTurn
}
last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
if err != nil {
return Message{}, err
}
if ok && svc.now().Sub(last) < nudgeInterval {
// The cooldown resets once the sender has acted (moved or chatted) since the last
// nudge — engagement clears the "don't spam" limit.
acted, err := svc.actedSince(ctx, gameID, senderID, last)
if err != nil {
return Message{}, err
}
if !acted {
return Message{}, ErrNudgeTooSoon
}
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, int16(1)<<uint(toMove))
if err != nil {
return Message{}, err
}
svc.metrics.recordChat(ctx, kindNudge)
if toMove >= 0 && toMove < len(seats) {
// Name the sender by their per-game seat snapshot, so the toast and the out-of-app push
// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
nudge := notify.Nudge(seats[toMove], gameID, senderID, senderName)
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
}
svc.pub.Publish(nudge)
}
return msg, nil
}
// actedSince reports whether senderID made a move or posted a chat message in the game
// after t — the events that reset the nudge cooldown.
func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) {
if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil {
return false, err
} else if ok && mv.After(t) {
return true, nil
}
if msg, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil {
return false, err
} else if ok && msg.After(t) {
return true, nil
}
return false, nil
}
// emitChat pushes a chat message to every seated player except the sender
// (best-effort live delivery; the recipients still read it via Messages).
func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message) {
intents := make([]notify.Intent, 0, len(seats))
for _, id := range seats {
if id == senderID {
continue
}
intents = append(intents, notify.ChatMessage(id, m.GameID, m.SenderID, m.ID.String(), m.Kind, m.Body, m.CreatedAt))
}
svc.pub.Publish(intents...)
}
// LastNudgeAt returns the time of the most recent nudge senderID sent in the game
// and true, or the zero time and false when there is none. The robot opponent
// uses it to notice a human nudge on its turn and answer promptly.
func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
return svc.store.lastNudgeAt(ctx, gameID, senderID)
}
// Messages returns the per-game chat visible to viewerID: the viewer must be a
// seated player. Messages from a sender the viewer has a block with (either
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
seats, _, _, err := svc.games.Participants(ctx, gameID)
if err != nil {
return nil, err
}
if !slices.Contains(seats, viewerID) {
return nil, ErrNotParticipant
}
viewer, err := svc.accounts.GetByID(ctx, viewerID)
if err != nil {
return nil, err
}
blocked := make(map[uuid.UUID]bool)
for _, seat := range seats {
if seat == viewerID {
continue
}
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
if err != nil {
return nil, err
}
if yes {
blocked[seat] = true
}
}
all, err := svc.store.listChatMessages(ctx, gameID)
if err != nil {
return nil, err
}
out := make([]Message, 0, len(all))
for _, m := range all {
if blocked[m.SenderID] {
continue
}
if m.Kind == kindMessage && viewer.BlockChat {
continue
}
out = append(out, m)
}
return out, nil
}
// parseIP returns a validated canonical IP string, or nil when raw is empty or
// not a valid address.
func parseIP(raw string) *string {
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return nil
}
canon := addr.String()
return &canon
}
// recipientMask is the initial unread bitmask for a text message: a set bit for every
// seated recipient (by seat index) other than the sender, except recipients in autoRead —
// a disguised robot opponent, whose message is born read since it never opens the chat.
// Still-empty (open) seats are skipped. With at most four seats the result always fits the
// smallint column.
func recipientMask(seats []uuid.UUID, senderID uuid.UUID, autoRead map[uuid.UUID]bool) int16 {
var mask int16
for i, id := range seats {
if id == uuid.Nil || id == senderID || autoRead[id] {
continue
}
mask |= int16(1) << uint(i)
}
return mask
}
// robotRecipients returns the seated recipients (every seat but the sender) that are robots.
// A disguised robot opponent never reads chat, so a message to it is born read (its unread
// bit is never set) — it would otherwise linger unread forever, skewing the unread count and
// the publish-to-read metric.
func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) {
var robots map[uuid.UUID]bool
for _, id := range seats {
if id == uuid.Nil || id == senderID {
continue
}
isRobot, err := svc.accounts.IsRobot(ctx, id)
if err != nil {
return nil, err
}
if isRobot {
if robots == nil {
robots = make(map[uuid.UUID]bool)
}
robots[id] = true
}
}
return robots, nil
}
// insertChatMessage stores one chat row, seeding its unread bitmask, and returns it.
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
id, err := uuid.NewV7()
if err != nil {
return Message{}, fmt.Errorf("social: new message id: %w", err)
}
var ipVal any = postgres.NULL
if ip != nil {
ipVal = postgres.String(*ip)
}
stmt := table.ChatMessages.INSERT(
table.ChatMessages.MessageID, table.ChatMessages.GameID, table.ChatMessages.SenderID,
table.ChatMessages.Kind, table.ChatMessages.Body, table.ChatMessages.SenderIP,
table.ChatMessages.UnreadSeats,
).VALUES(id, gameID, senderID, kind, body, ipVal, unreadMask).
RETURNING(table.ChatMessages.AllColumns)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Message{}, fmt.Errorf("social: insert chat message: %w", err)
}
return messageFromRow(row), nil
}
// listChatMessages returns a game's chat in chronological order.
func (s *Store) listChatMessages(ctx context.Context, gameID uuid.UUID) ([]Message, error) {
stmt := postgres.SELECT(table.ChatMessages.AllColumns).
FROM(table.ChatMessages).
WHERE(table.ChatMessages.GameID.EQ(postgres.UUID(gameID))).
ORDER_BY(table.ChatMessages.CreatedAt.ASC(), table.ChatMessages.MessageID.ASC())
var rows []model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list chat: %w", err)
}
out := make([]Message, 0, len(rows))
for _, r := range rows {
out = append(out, messageFromRow(r))
}
return out, nil
}
// lastNudgeAt returns the time of senderID's most recent nudge in gameID, if any.
func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
FROM(table.ChatMessages).
WHERE(
table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
AND(table.ChatMessages.Kind.EQ(postgres.String(kindNudge))),
).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return time.Time{}, false, nil
}
return time.Time{}, false, fmt.Errorf("social: last nudge: %w", err)
}
return row.CreatedAt, true, nil
}
// lastMessageAt returns the time of senderID's most recent non-nudge chat message in
// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale
// nudge no longer blocks a new one.
func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
FROM(table.ChatMessages).
WHERE(
table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
AND(table.ChatMessages.Kind.EQ(postgres.String(kindMessage))),
).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return time.Time{}, false, nil
}
return time.Time{}, false, fmt.Errorf("social: last message: %w", err)
}
return row.CreatedAt, true, nil
}
// messageFromRow projects a generated row into the public Message.
func messageFromRow(r model.ChatMessages) Message {
m := Message{
ID: r.MessageID,
GameID: r.GameID,
SenderID: r.SenderID,
Kind: r.Kind,
Body: r.Body,
CreatedAt: r.CreatedAt,
}
if r.SenderIP != nil {
m.SenderIP = *r.SenderIP
}
return m
}