feat(chat): unread read-receipts — lobby/game dot, history-open ack, admin + metrics #77

Merged
developer merged 3 commits from feature/chat-read-receipts into development 2026-06-17 09:52:23 +00:00
3 changed files with 80 additions and 6 deletions
Showing only changes of commit 20f2a5a011 - Show all commits
+41
View File
@@ -5,9 +5,13 @@ package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/social"
)
@@ -116,6 +120,43 @@ func TestNudgeClearedByMove(t *testing.T) {
}
}
// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled
// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the
// chat, so the message must not linger unread (skewing the count and the read metric).
func TestChatToRobotIsBornRead(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
human := provisionAccount(t)
robot, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-chat-"+uuid.NewString(), "Robbie")
if err != nil {
t.Fatalf("provision robot: %v", err)
}
g, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
// The human (seat 0, to move) chats the disguised robot; the message is born read.
msg, err := svc.PostMessage(ctx, g.ID, human, "good luck", "")
if err != nil {
t.Fatalf("post: %v", err)
}
if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: g.ID, UnreadOnly: true}, 50, 0); len(items) != 0 {
t.Errorf("a message to a robot should be born read, but the unread filter listed %d", len(items))
}
card, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail: %v", err)
}
for _, s := range card.Seats {
if s.AccountID == robot.ID && s.Role != "read" {
t.Errorf("robot seat role = %q, want read", s.Role)
}
}
}
// TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read
// breakdown of the message card.
func TestAdminChatUnreadFilterAndDetail(t *testing.T) {
+36 -5
View File
@@ -100,7 +100,12 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
if err := Clean(body); err != nil {
return Message{}, err
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID))
// 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
}
@@ -259,12 +264,14 @@ func parseIP(raw string) *string {
}
// 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 {
// 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 {
if id == uuid.Nil || id == senderID || autoRead[id] {
continue
}
mask |= int16(1) << uint(i)
@@ -272,6 +279,30 @@ func recipientMask(seats []uuid.UUID, senderID uuid.UUID) int16 {
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()
+3 -1
View File
@@ -524,7 +524,9 @@ disguised robot stays indistinguishable from a person.
- **Read receipts**: each `chat_messages` row carries an `unread_seats` bitmask — a set
bit per recipient seat that has **not** yet read it (the sender's own bit is never set).
A text message seeds the bits of every seated recipient; a nudge seeds only the awaited
player's. A seat's bit clears when that player **opens the move history or the chat**
player's. A **disguised robot opponent's bit is never set** — it never opens the chat, so
a message to it is **born read** (it would otherwise linger unread forever); a nudge to a
robot instead clears when the robot answers by moving, as for a human. A seat's bit clears when that player **opens the move history or the chat**
(`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a
history open is not a constant backend call), and a **nudge additionally clears when its
recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is