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, _, _, 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 } msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP)) if err != nil { return Message{}, err } 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 } 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 } msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil) if err != nil { return Message{}, err } if toMove >= 0 && toMove < len(seats) { svc.pub.Publish(notify.Nudge(seats[toMove], gameID, senderID)) } return msg, 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 } // 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 }