feat(chat): unread read-receipts — lobby/game dot, history-open ack, admin + metrics #77
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user