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 } // Blocker-side guard: a sender whose only opponents are people they have blocked cannot // post (the composer is hidden client-side; this is the server-side counterpart). In a // multi-player game with a non-blocked opponent the post is allowed and reaches them. if guard, err := svc.senderBlocksEveryOpponent(ctx, seats, senderID); err != nil { return Message{}, err } else if guard { return Message{}, ErrRecipientBlocked } // Recipients whose copy is born read so it never shows as unread: a disguised robot // opponent (never opens the chat) and any recipient who has blocked the sender — the // latter is also dropped from live delivery below, so the block stays invisible to the // blocked sender while the blocker sees nothing. autoRead, err := svc.robotRecipients(ctx, seats, senderID) if err != nil { return Message{}, err } suppressed, err := svc.blockedRecipients(ctx, seats, senderID) if err != nil { return Message{}, err } msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, mergeSeatSets(autoRead, suppressed))) if err != nil { return Message{}, err } svc.metrics.recordChat(ctx, kindMessage) svc.emitChat(seats, senderID, msg, suppressed) 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 } // The awaited player is the nudge's sole recipient. var target uuid.UUID if toMove >= 0 && toMove < len(seats) { target = seats[toMove] } // Blocker-side guard: a sender who has blocked the awaited player cannot nudge them // (the control is hidden client-side; this is the server-side counterpart). if target != uuid.Nil { if guard, err := svc.store.blockExists(ctx, senderID, target); err != nil { return Message{}, err } else if guard { return Message{}, ErrRecipientBlocked } } 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 } } // Suppress when the awaited player has blocked the sender: the nudge still persists and // counts toward the once-per-hour cooldown (the sender notices nothing) but is born read // (mask 0) and never delivered, so the blocker sees no nudge. var suppressed bool if target != uuid.Nil { suppressed, err = svc.store.blockExists(ctx, target, senderID) if err != nil { return Message{}, err } } var mask int16 if !suppressed && target != uuid.Nil { mask = int16(1) << uint(toMove) } msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, mask) if err != nil { return Message{}, err } svc.metrics.recordChat(ctx, kindNudge) if !suppressed && target != uuid.Nil { // Name the sender by their per-game seat snapshot, so the toast and the out-of-app push // read ": …"; an unresolved name (best-effort) falls back to the plain phrase. senderName, _ := svc.games.SeatName(ctx, gameID, senderID) nudge := notify.Nudge(target, gameID, senderID, senderName) 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 and any // recipient in suppressed — a recipient who has blocked the sender, who must never see // it (best-effort live delivery; the other recipients still read it via Messages). func (svc *Service) emitChat(seats []uuid.UUID, senderID uuid.UUID, m Message, suppressed map[uuid.UUID]bool) { intents := make([]notify.Intent, 0, len(seats)) for _, id := range seats { if id == senderID || suppressed[id] { 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 blocked are dropped — one // direction only, so a blocked player still sees the blocker's messages and never // notices the block — 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.blockExists(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 } // blockedRecipients returns the seated recipients (every non-empty seat but the sender) // that have blocked the sender. Their copy of a message or nudge is born read and is not // delivered — the store-but-hide that keeps a block invisible to the blocked sender while // denying the blocker any sight of what they send. func (svc *Service) blockedRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) { var out map[uuid.UUID]bool for _, id := range seats { if id == uuid.Nil || id == senderID { continue } yes, err := svc.store.blockExists(ctx, id, senderID) if err != nil { return nil, err } if yes { if out == nil { out = make(map[uuid.UUID]bool) } out[id] = true } } return out, nil } // senderBlocksEveryOpponent reports whether senderID has blocked every other seated // player (and there is at least one). It is the blocker-side chat guard: a player whose // only opponents are people they have blocked cannot post. In a multi-player game that // still has a non-blocked opponent it is false, so the player can talk to the others. func (svc *Service) senderBlocksEveryOpponent(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (bool, error) { opponents := 0 for _, id := range seats { if id == uuid.Nil || id == senderID { continue } opponents++ blocked, err := svc.store.blockExists(ctx, senderID, id) if err != nil { return false, err } if !blocked { return false, nil } } return opponents > 0, nil } // mergeSeatSets returns the union of two seat sets; either may be nil. It folds the // born-read recipients (disguised robots and recipients who blocked the sender) into one // mask input. func mergeSeatSets(a, b map[uuid.UUID]bool) map[uuid.UUID]bool { if len(b) == 0 { return a } if len(a) == 0 { return b } out := make(map[uuid.UUID]bool, len(a)+len(b)) for id := range a { out[id] = true } for id := range b { out[id] = true } return out } // 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 }