bfa8797f8c
Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged. New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer). Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
235 lines
7.1 KiB
Go
235 lines
7.1 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/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, _, _, err := svc.games.Participants(ctx, gameID)
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
if !slices.Contains(seats, senderID) {
|
|
return Message{}, ErrNotParticipant
|
|
}
|
|
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
|
|
}
|
|
return svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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 {
|
|
return Message{}, ErrNudgeTooSoon
|
|
}
|
|
return svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// insertChatMessage stores one chat row and returns it.
|
|
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string) (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,
|
|
).VALUES(id, gameID, senderID, kind, body, ipVal).
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|