Files
scrabble-game/backend/internal/social/chat.go
T
Ilia Denisov aaac816dc2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
feat(chat): unread read-receipts with lobby/game dot and history-open ack
Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).

UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
2026-06-17 11:12:38 +02:00

370 lines
12 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
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID))
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. 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) int16 {
var mask int16
for i, id := range seats {
if id == uuid.Nil || id == senderID {
continue
}
mask |= int16(1) << uint(i)
}
return mask
}
// 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
}