feat(social): asymmetric per-user block, in-game block control, admin lists
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.
- backend: a directional blockExists guard across chat/nudge/friends/invitations
(store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
the matchmaker excludes a block-related pair (both directions) from auto-match;
user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
(red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
This commit is contained in:
@@ -189,6 +189,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
|
||||
matchmaker := lobby.NewMatchmaker(games, robots, cfg.Lobby.RobotWait, cfg.Lobby.RobotWaitJitter, logger)
|
||||
matchmaker.SetNotifier(hub)
|
||||
matchmaker.SetBlocker(socialSvc)
|
||||
go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval)
|
||||
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
|
||||
invitations.SetNotifier(hub)
|
||||
|
||||
@@ -26,6 +26,11 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
|
||||
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
|
||||
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
|
||||
}, `/_gm/users/c3`},
|
||||
{"throttled", ThrottledView{
|
||||
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
|
||||
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
|
||||
|
||||
@@ -102,6 +102,36 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Friends</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Friends}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Blocks</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Blocks}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Blocked by</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .BlockedBy}}
|
||||
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
||||
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{if .TelegramID}}
|
||||
<section class="panel"><h2>Send Telegram message</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
|
||||
@@ -175,6 +175,21 @@ type UserDetailView struct {
|
||||
// grant form offers. The first role is the feedback ban (see internal/account).
|
||||
Roles []string
|
||||
KnownRoles []string
|
||||
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
|
||||
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
|
||||
// other account with the date it happened. They are the full truth; the asymmetric block
|
||||
// suppression that hides relationships from players never applies to the console.
|
||||
Blocks []RelationRow
|
||||
BlockedBy []RelationRow
|
||||
Friends []RelationRow
|
||||
}
|
||||
|
||||
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
||||
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
||||
type RelationRow struct {
|
||||
AccountID string
|
||||
DisplayName string
|
||||
Date string
|
||||
}
|
||||
|
||||
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
||||
|
||||
@@ -274,7 +274,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
|
||||
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
|
||||
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
|
||||
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
|
||||
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) {
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
@@ -319,7 +319,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
if seed&1 == 1 {
|
||||
seats = []seatInsert{{}, caller}
|
||||
}
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats)
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
|
||||
@@ -200,22 +200,27 @@ func openMatchKey(variant string, multipleWords bool) int64 {
|
||||
// order) used only when a game is created; callerName is the caller's display-name
|
||||
// snapshot, stamped on their seat whether they open a fresh game or fill another
|
||||
// player's open one.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||
return fmt.Errorf("open match lock: %w", e)
|
||||
}
|
||||
// 1. Another player's open game waiting for an opponent — fill its seat and start it.
|
||||
// A game whose waiting player is in exclude (the caller's per-user block set, either
|
||||
// direction) is skipped, so a block keeps the pair out of the same anonymous game;
|
||||
// an empty exclude (the "{}" literal) excludes nothing.
|
||||
var other uuid.UUID
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT g.game_id FROM backend.games g
|
||||
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
|
||||
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
|
||||
WHERE p.game_id = g.game_id AND p.account_id = $3)
|
||||
AND NOT EXISTS (SELECT 1 FROM backend.game_players b
|
||||
WHERE b.game_id = g.game_id AND b.account_id = ANY($4::uuid[]))
|
||||
ORDER BY g.created_at
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); {
|
||||
case e == nil:
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
|
||||
return er
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// TestBlockInstantReadsExistingUnread checks that blocking marks read any chat the blocked
|
||||
// user had left unread for the blocker in their shared games, so no stale unread badge
|
||||
// lingers for someone the blocker no longer sees.
|
||||
func TestBlockInstantReadsExistingUnread(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
sender, viewer := seats[0], seats[1] // seat 0 moves first, so it may chat
|
||||
|
||||
if _, err := svc.PostMessage(ctx, gameID, sender, "good luck", ""); err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, viewer); !unread {
|
||||
t.Fatal("viewer should have the message unread before blocking")
|
||||
}
|
||||
if err := svc.Block(ctx, viewer, sender); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, viewer); unread {
|
||||
t.Error("blocking should instant-read the blocked sender's prior unread message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for chat: a
|
||||
// message the blocked user sends is stored and visible to themselves, but is born read for
|
||||
// the blocker, never delivered to them, and hidden from their view.
|
||||
func TestChatFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocked, blocker := seats[0], seats[1] // seat 0 moves first, so the blocked user may chat
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.PostMessage(ctx, gameID, blocked, "hello there", ""); err != nil {
|
||||
t.Fatalf("blocked user's post = %v, want nil (it must look ordinary to them)", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
|
||||
t.Error("the blocked sender's message must be born read for the blocker")
|
||||
}
|
||||
if pub.delivered(blocker, notify.KindChatMessage) {
|
||||
t.Error("the blocked sender's message must not be delivered to the blocker")
|
||||
}
|
||||
if msgs, _ := svc.Messages(ctx, gameID, blocker); len(msgs) != 0 {
|
||||
t.Errorf("blocker still sees the blocked sender's message: %+v", msgs)
|
||||
}
|
||||
if msgs, _ := svc.Messages(ctx, gameID, blocked); len(msgs) != 1 {
|
||||
t.Errorf("the blocked sender should see their own message, got %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatToBlockedIsRejected checks the blocker-side guard: a player cannot post when their
|
||||
// only opponent is someone they have blocked (the composer is hidden client-side too).
|
||||
func TestChatToBlockedIsRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocker, blocked := seats[0], seats[1] // blocker moves first, so it is the one trying to chat
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.PostMessage(ctx, gameID, blocker, "hi", ""); !errors.Is(err, social.ErrRecipientBlocked) {
|
||||
t.Fatalf("chat to blocked opponent = %v, want ErrRecipientBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNudgeFromBlockedIsBornReadAndNotDelivered checks the store-but-hide path for nudge: a
|
||||
// nudge the blocked user sends is recorded (so their once-per-hour cooldown applies, looking
|
||||
// ordinary to them) but is born read and never delivered to the blocker.
|
||||
func TestNudgeFromBlockedIsBornReadAndNotDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocker, blocked := seats[0], seats[1] // seat 0 awaited; the blocked seat-1 player nudges it
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, blocked); err != nil {
|
||||
t.Fatalf("blocked user's nudge = %v, want nil (it must look ordinary to them)", err)
|
||||
}
|
||||
if unread, _ := svc.HasUnread(ctx, gameID, blocker); unread {
|
||||
t.Error("the blocked sender's nudge must be born read for the blocker")
|
||||
}
|
||||
if pub.delivered(blocker, notify.KindNudge) {
|
||||
t.Error("the blocked sender's nudge must not be delivered to the blocker")
|
||||
}
|
||||
// The nudge is still recorded, so the cooldown applies (the blocked user notices nothing).
|
||||
if _, ok, _ := svc.LastNudgeAt(ctx, gameID, blocked); !ok {
|
||||
t.Error("the suppressed nudge should still be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNudgeToBlockedIsRejected checks the blocker-side guard: a player cannot nudge the
|
||||
// awaited opponent when they have blocked them.
|
||||
func TestNudgeToBlockedIsRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
gameID, seats := newGameWithSeats(t, 2)
|
||||
blocked, blocker := seats[0], seats[1] // seat 0 awaited; the blocker (seat 1) tries to nudge it
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.Nudge(ctx, gameID, blocker); !errors.Is(err, social.ErrRecipientBlocked) {
|
||||
t.Fatalf("nudge to blocked opponent = %v, want ErrRecipientBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockPublishesToBlockerOnly checks that block and unblock confirm to the blocker (so
|
||||
// their other sessions update in place) and never reach the blocked user.
|
||||
func TestBlockPublishesToBlockerOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
blocker, blocked := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if !pub.notified(blocker, notify.NotifyUserBlocked) {
|
||||
t.Error("the blocker should receive a user_blocked event")
|
||||
}
|
||||
if pub.notified(blocked, notify.NotifyUserBlocked) {
|
||||
t.Error("the blocked user must never receive a user_blocked event")
|
||||
}
|
||||
if err := svc.Unblock(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("unblock: %v", err)
|
||||
}
|
||||
if !pub.notified(blocker, notify.NotifyUserUnblocked) {
|
||||
t.Error("the blocker should receive a user_unblocked event")
|
||||
}
|
||||
if pub.notified(blocked, notify.NotifyUserUnblocked) {
|
||||
t.Error("the blocked user must never receive a user_unblocked event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakingExcludesBlockedPlayers checks auto-match never pairs two players with a
|
||||
// block between them (either direction), while an unblocked third player still joins.
|
||||
func TestMatchmakingExcludesBlockedPlayers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
|
||||
soc := newSocialService()
|
||||
mm.SetBlocker(soc)
|
||||
a, b, c := provisionAccount(t), provisionAccount(t), provisionAccount(t)
|
||||
|
||||
if err := soc.Block(ctx, a, b); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
// The block is seen from both sides — the exclusion set unions both directions.
|
||||
if !contains(blockedWith(t, soc, a), b) || !contains(blockedWith(t, soc, b), a) {
|
||||
t.Fatal("a block must appear in BlockedWith for both the blocker and the blocked")
|
||||
}
|
||||
|
||||
// b opens a game awaiting an opponent.
|
||||
r1, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue b: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must open a game, not match")
|
||||
}
|
||||
// a must not join b's game (a blocked b): it opens its own instead.
|
||||
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
}
|
||||
if r2.Matched || r2.Game.ID == r1.Game.ID {
|
||||
t.Fatalf("a joined the blocked player's game %s (matched %v), want a fresh game", r1.Game.ID, r2.Matched)
|
||||
}
|
||||
// A third, unblocked player joins b's still-open game (the oldest), proving the exclusion
|
||||
// is specific to the blocked pair, not a blanket refusal.
|
||||
r3, err := mm.Enqueue(ctx, c, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue c: %v", err)
|
||||
}
|
||||
if !r3.Matched || r3.Game.ID != r1.Game.ID {
|
||||
t.Fatalf("unblocked c = (game %s, matched %v), want it to join b's open game %s", r3.Game.ID, r3.Matched, r1.Game.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSocialLists checks the admin user card's blocks / blocked-by / friends queries
|
||||
// return the full truth in both directions, including a friendship that a block overrides but
|
||||
// does not delete.
|
||||
func TestAdminSocialLists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
a, b := seats[0], seats[1]
|
||||
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if err := svc.Block(ctx, a, b); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
|
||||
blocks, err := svc.AdminBlocksBy(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("admin blocks by: %v", err)
|
||||
}
|
||||
if len(blocks) != 1 || blocks[0].AccountID != b || blocks[0].At.IsZero() {
|
||||
t.Errorf("a's blocks = %v, want [b] with a date", blocks)
|
||||
}
|
||||
blockedBy, err := svc.AdminBlockedBy(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatalf("admin blocked by: %v", err)
|
||||
}
|
||||
if len(blockedBy) != 1 || blockedBy[0].AccountID != a {
|
||||
t.Errorf("b's blocked-by = %v, want [a]", blockedBy)
|
||||
}
|
||||
// The friendship survives the block, so it still shows on both admin friend lists.
|
||||
friendsA, err := svc.AdminFriends(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("admin friends: %v", err)
|
||||
}
|
||||
if len(friendsA) != 1 || friendsA[0].AccountID != b || friendsA[0].At.IsZero() {
|
||||
t.Errorf("a's admin friends = %v, want [b] with a date", friendsA)
|
||||
}
|
||||
if friendsB, err := svc.AdminFriends(ctx, b); err != nil || len(friendsB) != 1 || friendsB[0].AccountID != a {
|
||||
t.Errorf("b's admin friends = %v (err %v), want [a]", friendsB, err)
|
||||
}
|
||||
}
|
||||
|
||||
// blockedWith reads the both-direction block set for id.
|
||||
func blockedWith(t *testing.T, soc *social.Service, id uuid.UUID) []uuid.UUID {
|
||||
t.Helper()
|
||||
ids, err := soc.BlockedWith(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("blocked with: %v", err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// contains reports whether ids includes want.
|
||||
func contains(ids []uuid.UUID, want uuid.UUID) bool {
|
||||
for _, id := range ids {
|
||||
if id == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestCountActiveQuickGames(t *testing.T) {
|
||||
// An open (awaiting-opponent) quick game counts.
|
||||
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
||||
}, time.Now().Add(time.Minute)); err != nil {
|
||||
}, time.Now().Add(time.Minute), nil); err != nil {
|
||||
t.Fatalf("open quick game: %v", err)
|
||||
}
|
||||
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
||||
|
||||
@@ -198,14 +198,36 @@ func TestInvitationLazyExpiry(t *testing.T) {
|
||||
func TestInvitationBlockedInvitee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
social := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
soc := newSocialService()
|
||||
|
||||
// The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware).
|
||||
inviter := provisionAccount(t)
|
||||
invitee := provisionAccount(t)
|
||||
if err := social.Block(ctx, invitee, inviter); err != nil {
|
||||
blockedInvitee := provisionAccount(t)
|
||||
if err := soc.Block(ctx, inviter, blockedInvitee); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create with a blocked invitee = %v, want ErrInvitationBlocked", err)
|
||||
}
|
||||
|
||||
// An invitee who has blocked the inviter is instead suppressed: the invitation is created
|
||||
// (no error — the inviter notices nothing), but that invitee is never notified and never
|
||||
// sees it in their list.
|
||||
inviter2 := provisionAccount(t)
|
||||
suppressed := provisionAccount(t)
|
||||
if err := soc.Block(ctx, suppressed, inviter2); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateInvitation(ctx, inviter2, []uuid.UUID{suppressed}, englishInvite()); err != nil {
|
||||
t.Fatalf("create with a suppressing invitee = %v, want nil", err)
|
||||
}
|
||||
if pub.notified(suppressed, notify.NotifyInvitation) {
|
||||
t.Error("an invitee who blocked the inviter must not be notified of the invitation")
|
||||
}
|
||||
if list, _ := svc.ListInvitations(ctx, suppressed); len(list) != 0 {
|
||||
t.Errorf("suppressed invitee's invitation list = %v, want empty", list)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func openParams(seed int64) game.CreateParams {
|
||||
|
||||
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
|
||||
t.Helper()
|
||||
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
|
||||
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open game: %v", err)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
starter := provisionAccount(t)
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
|
||||
}
|
||||
|
||||
joiner := provisionAccount(t)
|
||||
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
|
||||
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("join: %v", err)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,20 @@ func (c *capturePublisher) notified(user uuid.UUID, sub string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// delivered reports whether an intent of the given top-level kind (e.g. chat_message,
|
||||
// nudge) was published to user — used to assert that a blocked sender's message or nudge
|
||||
// never reaches the blocker.
|
||||
func (c *capturePublisher) delivered(user uuid.UUID, kind string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, in := range c.intents {
|
||||
if in.UserID == user && in.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
|
||||
// pending rather than blocked: robots no longer block friend requests, so the request
|
||||
// just sits unanswered and later expires — mirroring a human who ignores it.
|
||||
@@ -128,17 +142,53 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
|
||||
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
|
||||
}
|
||||
|
||||
// Block: the addressee has blocked the requester.
|
||||
c, d := provisionAccount(t), provisionAccount(t)
|
||||
if err := svc.Block(ctx, d, c); err != nil {
|
||||
// Block from the requester's own side: a requester who has blocked the addressee is
|
||||
// refused outright (they are the blocker and aware of it). The reverse direction — the
|
||||
// addressee having blocked the requester — is instead silently suppressed, covered by
|
||||
// TestFriendRequestFromBlockedIsSuppressed.
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
c, d := seats[0], seats[1]
|
||||
if err := svc.Block(ctx, c, d); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
|
||||
t.Fatalf("blocked send = %v, want ErrRequestBlocked", err)
|
||||
t.Fatalf("blocker's own request = %v, want ErrRequestBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockSeversFriendship(t *testing.T) {
|
||||
// TestFriendRequestFromBlockedIsSuppressed checks the store-but-hide path: when the
|
||||
// addressee has blocked the requester, the requester's friend request succeeds silently
|
||||
// (no error — they must not notice the block), is stored, but is never delivered to nor
|
||||
// surfaced for the blocker.
|
||||
func TestFriendRequestFromBlockedIsSuppressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
blocker, blocked := seats[0], seats[1]
|
||||
if err := svc.Block(ctx, blocker, blocked); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if err := svc.SendFriendRequest(ctx, blocked, blocker); err != nil {
|
||||
t.Fatalf("suppressed request = %v, want nil", err)
|
||||
}
|
||||
if pub.notified(blocker, notify.NotifyFriendRequest) {
|
||||
t.Error("blocker must not be notified of a suppressed request")
|
||||
}
|
||||
if got, _ := svc.ListIncomingRequests(ctx, blocker); len(got) != 0 {
|
||||
t.Errorf("blocker incoming = %v, want none (suppressed)", got)
|
||||
}
|
||||
// The blocked user sees an ordinary outgoing request — no leak of the block.
|
||||
if got, _ := svc.ListOutgoingRequests(ctx, blocked); len(got) != 1 || got[0] != blocker {
|
||||
t.Errorf("blocked outgoing = %v, want [blocker]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockKeepsFriendshipHiddenFromBlocker checks a block overrides but does not delete a
|
||||
// friendship: the blocker stops seeing the blocked friend, the blocked user's own list is
|
||||
// unchanged (so they never notice), and an unblock cleanly restores the friendship.
|
||||
func TestBlockKeepsFriendshipHiddenFromBlocker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newSocialService()
|
||||
_, seats := newGameWithSeats(t, 2)
|
||||
@@ -153,7 +203,16 @@ func TestBlockSeversFriendship(t *testing.T) {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
|
||||
t.Errorf("friendship must be severed by a block, got %v", friends)
|
||||
t.Errorf("blocker's friend list = %v, want the blocked friend hidden", friends)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a {
|
||||
t.Errorf("blocked user's friend list = %v, want [blocker] unchanged", friends)
|
||||
}
|
||||
if err := svc.Unblock(ctx, a, b); err != nil {
|
||||
t.Fatalf("unblock: %v", err)
|
||||
}
|
||||
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b {
|
||||
t.Errorf("after unblock, blocker's friend list = %v, want [b] restored", friends)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,14 @@ func (svc *InvitationService) emitInvitationUpdate(ctx context.Context, inv Invi
|
||||
}
|
||||
intents := make([]notify.Intent, 0, len(recipients))
|
||||
for _, id := range recipients {
|
||||
// An invitee who has blocked the inviter never sees this invitation, so they must not
|
||||
// receive its updates either (an update would otherwise re-add it to their lobby). The
|
||||
// inviter always gets updates; on a lookup error suppress rather than risk a leak.
|
||||
if id != inv.InviterID {
|
||||
if blk, err := svc.blocker.Blocks(ctx, id, inv.InviterID); err != nil || blk {
|
||||
continue
|
||||
}
|
||||
}
|
||||
intents = append(intents, notify.NotificationInvitationUpdate(id, summary))
|
||||
}
|
||||
svc.pub.Publish(intents...)
|
||||
@@ -211,6 +219,11 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||
}
|
||||
seen := map[uuid.UUID]bool{inviterID: true}
|
||||
// suppressed collects invitees who have blocked the inviter: the invitation is still
|
||||
// created and persisted for them, but they are never notified and never see it (their
|
||||
// friendship with the inviter survived the block, so they can still appear in the
|
||||
// inviter's picker — they must not learn of the block).
|
||||
suppressed := make(map[uuid.UUID]bool)
|
||||
for _, id := range inviteeIDs {
|
||||
if seen[id] {
|
||||
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
|
||||
@@ -222,13 +235,22 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
}
|
||||
return Invitation{}, err
|
||||
}
|
||||
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
|
||||
// A block the inviter placed refuses the invite (the inviter is aware; the picker hides
|
||||
// the invitee anyway). A block the invitee placed on the inviter is instead suppressed.
|
||||
iBlock, err := svc.blocker.Blocks(ctx, inviterID, id)
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if blocked {
|
||||
if iBlock {
|
||||
return Invitation{}, ErrInvitationBlocked
|
||||
}
|
||||
theyBlock, err := svc.blocker.Blocks(ctx, id, inviterID)
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
if theyBlock {
|
||||
suppressed[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
@@ -253,7 +275,18 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
svc.emitInvitation(ctx, inv, inviteeIDs)
|
||||
// Notify every invitee except those who have blocked the inviter (their copy stays
|
||||
// stored but unseen — the store-but-hide that keeps the block invisible).
|
||||
notifyIDs := inviteeIDs
|
||||
if len(suppressed) > 0 {
|
||||
notifyIDs = make([]uuid.UUID, 0, len(inviteeIDs))
|
||||
for _, id := range inviteeIDs {
|
||||
if !suppressed[id] {
|
||||
notifyIDs = append(notifyIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
svc.emitInvitation(ctx, inv, notifyIDs)
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
@@ -347,6 +380,17 @@ func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uui
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hide an invitation whose inviter the viewer has blocked: it stays stored but must
|
||||
// never surface to the blocker. The viewer's own invitations (as inviter) are unaffected.
|
||||
if inv.InviterID != accountID {
|
||||
blocked, err := svc.blocker.Blocks(ctx, accountID, inv.InviterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -38,11 +38,15 @@ type RobotProvider interface {
|
||||
PickNamed(variant engine.Variant) (uuid.UUID, string, error)
|
||||
}
|
||||
|
||||
// Blocker reports whether two accounts have a block between them (either
|
||||
// direction). social.Service satisfies it; the lobby uses it to refuse
|
||||
// invitations between blocked accounts.
|
||||
// Blocker exposes the per-user block graph the lobby needs. social.Service satisfies it.
|
||||
// Invitations consult the exact direction (Blocks) so the inviter's block refuses while a
|
||||
// block the invitee placed on the inviter is silently suppressed; auto-match consults
|
||||
// BlockedWith so a block either way keeps the pair out of the same anonymous game.
|
||||
type Blocker interface {
|
||||
IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||
// Blocks reports whether blocker has blocked blocked (exact direction).
|
||||
Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error)
|
||||
// BlockedWith returns every account in a block with accountID in either direction.
|
||||
BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error)
|
||||
}
|
||||
|
||||
// Auto-match defaults: a casual two-player game on the longest move clock with one
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
|
||||
// it.
|
||||
type GameMatcher interface {
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
|
||||
// OpenOrJoin joins the caller into another waiting player's open game or opens a fresh
|
||||
// one; exclude lists account IDs whose open game must never be joined (the caller's
|
||||
// per-user block set), so a block keeps the pair out of the same anonymous game.
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error)
|
||||
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error)
|
||||
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
@@ -35,8 +38,9 @@ type GameMatcher interface {
|
||||
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
|
||||
// concurrent use.
|
||||
//
|
||||
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
|
||||
// friends, chat and invitations between known players).
|
||||
// Auto-match is anonymous, but it still consults per-user blocks: a player is never
|
||||
// paired into a game whose waiting opponent they have blocked, or who has blocked them
|
||||
// (either direction), so a block keeps two players apart everywhere.
|
||||
type Matchmaker struct {
|
||||
games GameMatcher
|
||||
robots RobotProvider
|
||||
@@ -44,6 +48,7 @@ type Matchmaker struct {
|
||||
jitter time.Duration
|
||||
clock func() time.Time
|
||||
pub notify.Publisher
|
||||
blocker Blocker
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
@@ -75,6 +80,16 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetBlocker installs the per-user block source used to keep auto-match from pairing a
|
||||
// player with someone they have a block with (either direction). It must be called
|
||||
// during startup wiring; the default is no blocker, in which case auto-match pairs
|
||||
// anonymously (no block exclusion).
|
||||
func (m *Matchmaker) SetBlocker(b Blocker) {
|
||||
if b != nil {
|
||||
m.blocker = b
|
||||
}
|
||||
}
|
||||
|
||||
// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
|
||||
// in, and whether it already had an opponent (they joined a waiting game) rather than
|
||||
// being freshly opened and still awaiting one.
|
||||
@@ -90,7 +105,17 @@ type EnqueueResult struct {
|
||||
// caller's own. When the caller joins an existing game, opponent_joined is pushed to
|
||||
// that game's waiting starter.
|
||||
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
|
||||
// Exclude every account the caller has a block with (either direction) from the
|
||||
// candidate open games, so auto-match never pairs a blocked pair into one game.
|
||||
var exclude []uuid.UUID
|
||||
if m.blocker != nil {
|
||||
var err error
|
||||
exclude, err = m.blocker.BlockedWith(ctx, accountID)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
}
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type stubMatcher struct {
|
||||
openErr error
|
||||
openCalls int
|
||||
lastDeadline time.Time
|
||||
lastExclude []uuid.UUID
|
||||
|
||||
expired []game.OpenGame
|
||||
|
||||
@@ -39,9 +40,10 @@ type stubMatcher struct {
|
||||
createParams game.CreateParams
|
||||
}
|
||||
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time, exclude []uuid.UUID) (game.Game, bool, error) {
|
||||
s.openCalls++
|
||||
s.lastDeadline = deadline
|
||||
s.lastExclude = exclude
|
||||
return s.openGame, s.openJoined, s.openErr
|
||||
}
|
||||
|
||||
@@ -95,6 +97,33 @@ type capturePub struct{ intents []notify.Intent }
|
||||
|
||||
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
|
||||
|
||||
// fakeBlocker is a Blocker returning a canned both-direction block set.
|
||||
type fakeBlocker struct {
|
||||
with []uuid.UUID
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeBlocker) Blocks(context.Context, uuid.UUID, uuid.UUID) (bool, error) { return false, nil }
|
||||
func (f *fakeBlocker) BlockedWith(context.Context, uuid.UUID) ([]uuid.UUID, error) {
|
||||
return f.with, f.err
|
||||
}
|
||||
|
||||
// TestEnqueueExcludesBlockedStarters checks the matchmaker passes the caller's block set
|
||||
// (both directions) to OpenOrJoin as the exclusion list, so auto-match never joins a game
|
||||
// whose waiting player has a block with the caller.
|
||||
func TestEnqueueExcludesBlockedStarters(t *testing.T) {
|
||||
caller, blocked := uuid.New(), uuid.New()
|
||||
m := &stubMatcher{openGame: twoSeatGame(caller, uuid.Nil)}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetBlocker(&fakeBlocker{with: []uuid.UUID{blocked}})
|
||||
if _, err := mm.Enqueue(context.Background(), caller, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if !slices.Contains(m.lastExclude, blocked) {
|
||||
t.Fatalf("OpenOrJoin exclude = %v, want it to contain the blocked id %s", m.lastExclude, blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
|
||||
// (uuid.Nil for a still-empty opponent seat).
|
||||
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
|
||||
|
||||
@@ -62,6 +62,16 @@ const (
|
||||
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
|
||||
// banner set rides the profile response). In-app only.
|
||||
NotifyBanner = "banner"
|
||||
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
|
||||
// carrying the blocked account, so every one of the blocker's sessions updates the
|
||||
// in-game block/add-friend controls and the struck name in place. It is delivered
|
||||
// only to the blocker — never to the blocked user, who must not learn of the block —
|
||||
// and is in-app only (no out-of-app push: blocking is the viewer's own action).
|
||||
NotifyUserBlocked = "user_blocked"
|
||||
// NotifyUserUnblocked confirms an unblock to the (former) blocker, carrying the
|
||||
// unblocked account, so their open game screens restore the controls and un-strike
|
||||
// the name in place. Like NotifyUserBlocked it reaches only the actor and is in-app only.
|
||||
NotifyUserUnblocked = "user_unblocked"
|
||||
)
|
||||
|
||||
// Intent is one live event destined for a single user. Payload is the
|
||||
|
||||
@@ -389,9 +389,30 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
view.Roles = roles
|
||||
}
|
||||
view.KnownRoles = account.KnownRoles
|
||||
if s.social != nil {
|
||||
if rels, err := s.social.AdminBlocksBy(ctx, id); err == nil {
|
||||
view.Blocks = relationRows(rels)
|
||||
}
|
||||
if rels, err := s.social.AdminBlockedBy(ctx, id); err == nil {
|
||||
view.BlockedBy = relationRows(rels)
|
||||
}
|
||||
if rels, err := s.social.AdminFriends(ctx, id); err == nil {
|
||||
view.Friends = relationRows(rels)
|
||||
}
|
||||
}
|
||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||
}
|
||||
|
||||
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
|
||||
// user card renders.
|
||||
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
|
||||
out := make([]adminconsole.RelationRow, 0, len(rels))
|
||||
for _, r := range rels {
|
||||
out = append(out, adminconsole.RelationRow{AccountID: r.AccountID.String(), DisplayName: r.DisplayName, Date: fmtTime(r.At)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// consoleUserMessage sends an operator Telegram message to one user.
|
||||
func (s *Server) consoleUserMessage(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block
|
||||
// is mutual in effect (the social checks apply it both ways) and severs any
|
||||
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
|
||||
// account-ref resolution.
|
||||
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block is asymmetric:
|
||||
// the blocker stops seeing everything from the blocked user (chat, nudge, requests,
|
||||
// invitations, matchmaking) while the blocked user notices nothing; it overrides — but does
|
||||
// not delete — any friendship (an unblock restores it). They reuse the friend handlers'
|
||||
// targetIDRequest and account-ref resolution.
|
||||
|
||||
// blockListDTO is the accounts the caller has blocked.
|
||||
type blockListDTO struct {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AdminRelation is one entry in the admin user card's blocks / blocked-by / friends lists:
|
||||
// the other account, its display name, and when the relationship was recorded. It is the
|
||||
// unfiltered truth — the asymmetric block suppression that hides relationships from players
|
||||
// never applies to the operator console.
|
||||
type AdminRelation struct {
|
||||
AccountID uuid.UUID
|
||||
DisplayName string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// AdminBlocksBy returns the accounts blockerID has blocked, with display names and block
|
||||
// times, newest first — the admin card's "blocks" list.
|
||||
func (svc *Service) AdminBlocksBy(ctx context.Context, blockerID uuid.UUID) ([]AdminRelation, error) {
|
||||
return svc.store.adminBlocks(ctx, blockerID, true)
|
||||
}
|
||||
|
||||
// AdminBlockedBy returns the accounts that have blocked blockedID, with names and times,
|
||||
// newest first — the admin card's "blocked by" list.
|
||||
func (svc *Service) AdminBlockedBy(ctx context.Context, blockedID uuid.UUID) ([]AdminRelation, error) {
|
||||
return svc.store.adminBlocks(ctx, blockedID, false)
|
||||
}
|
||||
|
||||
// AdminFriends returns accountID's accepted friendships in either direction, with the other
|
||||
// account's name and when the friendship was accepted, newest first — the admin card's
|
||||
// "friends" list.
|
||||
func (svc *Service) AdminFriends(ctx context.Context, accountID uuid.UUID) ([]AdminRelation, error) {
|
||||
const q = `SELECT other_id, a.display_name, at FROM (
|
||||
SELECT CASE WHEN f.requester_id = $1 THEN f.addressee_id ELSE f.requester_id END AS other_id,
|
||||
COALESCE(f.responded_at, f.created_at) AS at
|
||||
FROM backend.friendships f
|
||||
WHERE f.status = 'accepted' AND (f.requester_id = $1 OR f.addressee_id = $1)
|
||||
) rel
|
||||
JOIN backend.accounts a ON a.account_id = rel.other_id
|
||||
ORDER BY at DESC`
|
||||
return svc.store.adminRelations(ctx, q, accountID)
|
||||
}
|
||||
|
||||
// adminBlocks reads the blocks touching id: when asBlocker the rows where id is the blocker
|
||||
// (the other side is who they blocked), otherwise the rows where id is the blocked (the
|
||||
// other side is who blocked them).
|
||||
func (s *Store) adminBlocks(ctx context.Context, id uuid.UUID, asBlocker bool) ([]AdminRelation, error) {
|
||||
q := `SELECT b.blocked_id, a.display_name, b.created_at
|
||||
FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocked_id
|
||||
WHERE b.blocker_id = $1 ORDER BY b.created_at DESC`
|
||||
if !asBlocker {
|
||||
q = `SELECT b.blocker_id, a.display_name, b.created_at
|
||||
FROM backend.blocks b JOIN backend.accounts a ON a.account_id = b.blocker_id
|
||||
WHERE b.blocked_id = $1 ORDER BY b.created_at DESC`
|
||||
}
|
||||
return s.adminRelations(ctx, q, id)
|
||||
}
|
||||
|
||||
// adminRelations runs an admin list query of the (account_id, display_name, at) shape.
|
||||
func (s *Store) adminRelations(ctx context.Context, query string, id uuid.UUID) ([]AdminRelation, error) {
|
||||
rows, err := s.db.QueryContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("social: admin relations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminRelation
|
||||
for rows.Next() {
|
||||
var r AdminRelation
|
||||
if err := rows.Scan(&r.AccountID, &r.DisplayName, &r.At); err != nil {
|
||||
return nil, fmt.Errorf("social: scan admin relation: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -10,24 +10,41 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// Block records that blockerID has blocked blockedID. Blocking severs any
|
||||
// friendship or pending request between the two and, through the mutual block
|
||||
// checks, suppresses chat visibility and new requests/invitations in both
|
||||
// directions. It is idempotent.
|
||||
// Block records that blockerID has blocked blockedID. The block is asymmetric and
|
||||
// non-destructive: it suppresses everything the blocked user sends toward the blocker
|
||||
// (chat, nudge, friend requests, invitations are persisted but never delivered or
|
||||
// surfaced) and hides the blocked user from the blocker's lists, while the blocked
|
||||
// user notices nothing. It does NOT delete the friendship — an unblock cleanly
|
||||
// restores it — and keeps every stored row. As a courtesy it marks any of the blocked
|
||||
// user's still-unread chat entries in their shared games read at once, so no stale
|
||||
// unread badge lingers for someone the blocker no longer sees. A user_blocked event is
|
||||
// delivered to the blocker (only) so their other sessions update in place. It is idempotent.
|
||||
func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error {
|
||||
if blockerID == blockedID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
return svc.store.insertBlock(ctx, blockerID, blockedID)
|
||||
if err := svc.store.insertBlock(ctx, blockerID, blockedID); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserBlocked, svc.accountRef(ctx, blockedID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unblock removes blockerID's block on blockedID. It is idempotent.
|
||||
// Unblock removes blockerID's block on blockedID and confirms it to the (former)
|
||||
// blocker with a user_unblocked event so their open game screens restore the controls
|
||||
// and un-strike the name in place. Any friendship the block had been overriding becomes
|
||||
// effective again. It is idempotent.
|
||||
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error {
|
||||
return svc.store.deleteBlock(ctx, blockerID, blockedID)
|
||||
if err := svc.store.deleteBlock(ctx, blockerID, blockedID); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(blockerID, notify.NotifyUserUnblocked, svc.accountRef(ctx, blockedID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBlocks returns the account IDs blockerID has blocked.
|
||||
@@ -35,11 +52,43 @@ func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid
|
||||
return svc.store.listBlocks(ctx, blockerID)
|
||||
}
|
||||
|
||||
// BlockedWith returns every account that has a block with accountID in either
|
||||
// direction (those accountID blocked, plus those who blocked accountID), de-duplicated.
|
||||
// The matchmaker excludes them so a block — either way — keeps the pair out of the same
|
||||
// anonymous auto-match game.
|
||||
func (svc *Service) BlockedWith(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
mine, err := svc.store.listBlocks(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
theirs, err := svc.store.listBlockedBy(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[uuid.UUID]struct{}, len(mine)+len(theirs))
|
||||
out := make([]uuid.UUID, 0, len(mine)+len(theirs))
|
||||
for _, id := range append(mine, theirs...) {
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsBlocked reports whether a block stands between a and b in either direction.
|
||||
func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
return svc.store.isBlocked(ctx, a, b)
|
||||
}
|
||||
|
||||
// Blocks reports whether blocker has blocked blocked — the exact direction, not the
|
||||
// symmetric IsBlocked. It backs the directional guards (chat, friend requests,
|
||||
// invitations) that must tell the blocker (refuse) from the blocked (silently suppress).
|
||||
func (svc *Service) Blocks(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
|
||||
return svc.store.blockExists(ctx, blocker, blocked)
|
||||
}
|
||||
|
||||
// isBlocked reports whether a block row exists between a and b in either direction.
|
||||
func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
@@ -58,14 +107,33 @@ func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// insertBlock severs any friendship between the pair and inserts the block, in one
|
||||
// transaction; a duplicate block is ignored.
|
||||
// blockExists reports whether blocker has blocked blocked — the exact direction, not
|
||||
// the symmetric isBlocked. The asymmetric block turns on this one-directional test:
|
||||
// the blocker filters out and refuses the blocked user, while the blocked user's own
|
||||
// actions are silently suppressed rather than refused, so they never notice.
|
||||
func (s *Store) blockExists(ctx context.Context, blocker, blocked uuid.UUID) (bool, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
FROM(table.Blocks).
|
||||
WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)).
|
||||
AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))).
|
||||
LIMIT(1)
|
||||
var row model.Blocks
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("social: block exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// insertBlock inserts the block and, in the same transaction, marks read every chat
|
||||
// entry the blocked user authored that the blocker had still left unread across their
|
||||
// shared games — so no stale unread badge lingers for someone the blocker no longer
|
||||
// sees. It deliberately leaves the friendship intact (the block overrides it; an
|
||||
// unblock restores it). A duplicate block is ignored.
|
||||
func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
del := table.Friendships.DELETE().WHERE(edgeEither(blocker, blocked))
|
||||
if _, err := del.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("clear friendship on block: %w", err)
|
||||
}
|
||||
ins := table.Blocks.
|
||||
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
|
||||
VALUES(blocker, blocked).
|
||||
@@ -73,6 +141,17 @@ func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) err
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert block: %w", err)
|
||||
}
|
||||
// Clear the blocker's unread bit on every entry the blocked user sent, in any game
|
||||
// they share, resolving the blocker's seat through game_players. The bitwise terms
|
||||
// are cast through int4 so the operators resolve unambiguously (as in markRead).
|
||||
const clearUnread = `UPDATE backend.chat_messages m
|
||||
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
|
||||
FROM backend.game_players p
|
||||
WHERE p.account_id = $1 AND p.game_id = m.game_id
|
||||
AND m.sender_id = $2 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
|
||||
if _, err := tx.ExecContext(ctx, clearUnread, blocker, blocked); err != nil {
|
||||
return fmt.Errorf("clear unread on block: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -104,3 +183,21 @@ func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID,
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listBlockedBy returns the accounts that have blocked blocked — the reverse of
|
||||
// listBlocks. The matchmaker unions the two so a block in either direction keeps a
|
||||
// pair out of the same auto-match game.
|
||||
func (s *Store) listBlockedBy(ctx context.Context, blocked uuid.UUID) ([]uuid.UUID, error) {
|
||||
stmt := postgres.SELECT(table.Blocks.BlockerID).
|
||||
FROM(table.Blocks).
|
||||
WHERE(table.Blocks.BlockedID.EQ(postgres.UUID(blocked)))
|
||||
var rows []model.Blocks
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("social: list blocked by: %w", err)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.BlockerID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
+124
-13
@@ -100,17 +100,32 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
|
||||
if err := Clean(body); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
// A disguised robot opponent never reads chat, so its recipient bit is born clear.
|
||||
// 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
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, autoRead))
|
||||
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)
|
||||
svc.emitChat(seats, senderID, msg, suppressed)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
@@ -138,6 +153,20 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
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
|
||||
@@ -153,16 +182,30 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
return Message{}, ErrNudgeTooSoon
|
||||
}
|
||||
}
|
||||
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, int16(1)<<uint(toMove))
|
||||
// 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 toMove >= 0 && toMove < len(seats) {
|
||||
if !suppressed && target != uuid.Nil {
|
||||
// Name the sender by their per-game seat snapshot, so the toast and the out-of-app push
|
||||
// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
|
||||
senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
|
||||
nudge := notify.Nudge(seats[toMove], gameID, senderID, senderName)
|
||||
nudge := notify.Nudge(target, gameID, senderID, senderName)
|
||||
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
|
||||
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
|
||||
}
|
||||
@@ -187,12 +230,13 @@ func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID,
|
||||
return false, 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) {
|
||||
// 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 {
|
||||
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))
|
||||
@@ -208,8 +252,9 @@ func (svc *Service) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -227,7 +272,7 @@ func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([
|
||||
if seat == viewerID {
|
||||
continue
|
||||
}
|
||||
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
|
||||
yes, err := svc.store.blockExists(ctx, viewerID, seat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -303,6 +348,72 @@ func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, send
|
||||
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()
|
||||
|
||||
@@ -51,7 +51,11 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
if requesterID == addresseeID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID)
|
||||
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
theyBlockMe, err := svc.store.blockExists(ctx, addresseeID, requesterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,7 +66,17 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
}
|
||||
return err
|
||||
}
|
||||
if blocked || addressee.BlockFriendRequests {
|
||||
if iBlockThem {
|
||||
// The requester has blocked the addressee — they are the blocker and aware of it.
|
||||
return ErrRequestBlocked
|
||||
}
|
||||
// When the addressee has blocked the requester, the request still proceeds and persists
|
||||
// by the normal logic below, but it is never delivered and the blocker never sees it
|
||||
// (ListIncomingRequests filters it out) — the blocked requester must not learn of the
|
||||
// block. The flag gates only the notification; it also bypasses the addressee's
|
||||
// block_friend_requests toggle so the suppressed request looks ordinary to the requester.
|
||||
suppressed := theyBlockMe
|
||||
if !suppressed && addressee.BlockFriendRequests {
|
||||
return ErrRequestBlocked
|
||||
}
|
||||
shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID)
|
||||
@@ -102,7 +116,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
if !suppressed {
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -112,7 +128,9 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
}
|
||||
return err
|
||||
}
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
if !suppressed {
|
||||
svc.pub.Publish(notify.NotificationAccount(addresseeID, notify.NotifyFriendRequest, svc.accountRef(ctx, requesterID)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -164,15 +182,55 @@ func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID)
|
||||
return svc.store.deleteFriendship(ctx, accountID, otherID)
|
||||
}
|
||||
|
||||
// ListFriends returns the account IDs that are accepted friends of accountID.
|
||||
// ListFriends returns the account IDs that are accepted friends of accountID, with any
|
||||
// the caller has blocked filtered out: a block overrides — but does not delete — the
|
||||
// friendship, so the blocker stops seeing the blocked friend while the blocked user's own
|
||||
// list still shows the blocker (they never notice).
|
||||
func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listFriends(ctx, accountID)
|
||||
ids, err := svc.store.listFriends(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.dropBlocked(ctx, accountID, ids)
|
||||
}
|
||||
|
||||
// ListIncomingRequests returns the account IDs that have a live (not yet expired)
|
||||
// pending friend request awaiting accountID's response.
|
||||
// pending friend request awaiting accountID's response, with any from a requester the
|
||||
// caller has blocked filtered out (their suppressed request stays stored but unseen).
|
||||
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
|
||||
return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||
ids, err := svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.dropBlocked(ctx, accountID, ids)
|
||||
}
|
||||
|
||||
// dropBlocked removes from ids every account the viewer has blocked. It keeps the
|
||||
// asymmetric block one-directional: the blocker stops seeing those they blocked in their
|
||||
// friend / incoming-request lists, while the blocked user's lists are never filtered.
|
||||
func (svc *Service) dropBlocked(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) ([]uuid.UUID, error) {
|
||||
if len(ids) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
blockedIDs, err := svc.store.listBlocks(ctx, viewer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blockedIDs) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
blocked := make(map[uuid.UUID]struct{}, len(blockedIDs))
|
||||
for _, id := range blockedIDs {
|
||||
blocked[id] = struct{}{}
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, b := blocked[id]; b {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListOutgoingRequests returns the account IDs the caller has already requested and
|
||||
|
||||
@@ -80,6 +80,10 @@ var (
|
||||
ErrMessageNotFound = errors.New("social: message not found")
|
||||
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
|
||||
ErrChatBlocked = errors.New("social: chat is disabled for this account")
|
||||
// ErrRecipientBlocked is returned when a player tries to chat to, or nudge, someone
|
||||
// they have blocked — the blocker-side guard behind the hidden composer (a blocked
|
||||
// player's own sends are never refused, only silently suppressed, so they never notice).
|
||||
ErrRecipientBlocked = errors.New("social: cannot message a blocked player")
|
||||
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
|
||||
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
|
||||
// ErrEmptyMessage is returned when a chat message is blank after trimming.
|
||||
|
||||
Reference in New Issue
Block a user