package social import ( "context" "fmt" "slices" "time" "github.com/google/uuid" "go.uber.org/zap" ) // robotFriendRequestTTL is how long a per-game disguised-robot friend request is kept // after its game has finished before the reaper deletes it. It is a sibling of // friendRequestTTL; the request is housekeeping for the in-game "request sent" state, // so it is purged once the finished game is well past its lobby lifetime. const robotFriendRequestTTL = 7 * 24 * time.Hour // robotFriendRequestReapInterval is how often the reaper sweeps expired rows. const robotFriendRequestReapInterval = time.Hour // RobotFriendRequest is one per-game friend request to a disguised-robot opponent: the // row id, the game name the player saw, and the game + seat it was sent in (so the // in-game scoreboard can re-mark that seat as already requested). type RobotFriendRequest struct { ID uuid.UUID DisplayName string GameID uuid.UUID Seat int } // RequestInGame sends a friend request to addresseeID for requesterID from within gameID. // A human is requested normally (the friendships table, via SendFriendRequest). A // disguised-robot opponent is instead recorded per-game in robot_friend_requests — never // the shared robot account — so the matchmaker keeps robots free, the same robot stays // un-requested in the requester's other games (under its other names), and the in-game // "request sent" state is pinned to this seat rather than the shared account. The robot // ignores the request (it never becomes a friendship); the reaper deletes the row once // the game is long finished. It is the entry point for the in-game add-friend control; // the friend-code path goes elsewhere. func (svc *Service) RequestInGame(ctx context.Context, requesterID, addresseeID, gameID uuid.UUID) error { if requesterID == addresseeID { return ErrSelfRelation } // A guest cannot use friends: a durable-account feature (the UI hides it). if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil { return err } else if acc.IsGuest { return ErrGuestForbidden } isRobot, err := svc.accounts.IsRobot(ctx, addresseeID) if err != nil { return err } if !isRobot { return svc.SendFriendRequest(ctx, requesterID, addresseeID) } if gameID == uuid.Nil { return ErrNotParticipant } seats, _, _, err := svc.games.Participants(ctx, gameID) if err != nil { return err } seat := slices.Index(seats, addresseeID) if seat < 0 { return ErrNotParticipant } name, _ := svc.games.SeatName(ctx, gameID, addresseeID) return svc.store.insertRobotFriendRequest(ctx, requesterID, gameID, seat, addresseeID, name) } // ListRobotFriendRequests returns requesterID's per-game disguised-robot friend // requests, newest first. func (svc *Service) ListRobotFriendRequests(ctx context.Context, requesterID uuid.UUID) ([]RobotFriendRequest, error) { return svc.store.listRobotFriendRequests(ctx, requesterID) } // insertRobotFriendRequest records a per-game robot friend request; a duplicate (same // requester, game, seat) is ignored. func (s *Store) insertRobotFriendRequest(ctx context.Context, requester, gameID uuid.UUID, seat int, robotID uuid.UUID, name string) error { id, err := uuid.NewV7() if err != nil { return fmt.Errorf("social: new robot friend request id: %w", err) } const q = `INSERT INTO backend.robot_friend_requests (id, requester_id, game_id, seat, robot_id, display_name) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (requester_id, game_id, seat) DO NOTHING` if _, err := s.db.ExecContext(ctx, q, id, requester, gameID, int16(seat), robotID, name); err != nil { return fmt.Errorf("social: insert robot friend request: %w", err) } return nil } // listRobotFriendRequests returns requester's robot friend requests, newest first. func (s *Store) listRobotFriendRequests(ctx context.Context, requester uuid.UUID) ([]RobotFriendRequest, error) { const q = `SELECT id, display_name, game_id, seat FROM backend.robot_friend_requests WHERE requester_id = $1 ORDER BY created_at DESC` rows, err := s.db.QueryContext(ctx, q, requester) if err != nil { return nil, fmt.Errorf("social: list robot friend requests: %w", err) } defer rows.Close() var out []RobotFriendRequest for rows.Next() { var r RobotFriendRequest var seat int16 if err := rows.Scan(&r.ID, &r.DisplayName, &r.GameID, &seat); err != nil { return nil, fmt.Errorf("social: scan robot friend request: %w", err) } r.Seat = int(seat) out = append(out, r) } return out, rows.Err() } // ReapExpiredRobotFriendRequests deletes every per-game robot friend request whose game has // been finished for longer than robotFriendRequestTTL, reporting how many rows were removed. // Rows for active (not-yet-finished) games are kept so the in-game "request sent" state // survives. It backs the RobotFriendRequestReaper and is directly callable in tests. func (svc *Service) ReapExpiredRobotFriendRequests(ctx context.Context) (int64, error) { return svc.store.deleteExpiredRobotFriendRequests(ctx, svc.now().Add(-robotFriendRequestTTL)) } // deleteExpiredRobotFriendRequests removes robot friend requests whose game has been // finished since before cutoff, reporting how many rows were deleted. func (s *Store) deleteExpiredRobotFriendRequests(ctx context.Context, cutoff time.Time) (int64, error) { const q = `DELETE FROM backend.robot_friend_requests r USING backend.games g WHERE r.game_id = g.game_id AND g.status = 'finished' AND g.finished_at < $1` res, err := s.db.ExecContext(ctx, q, cutoff) if err != nil { return 0, fmt.Errorf("social: delete expired robot friend requests: %w", err) } n, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("social: delete expired robot friend requests rows: %w", err) } return n, nil } // RobotFriendRequestReaper periodically reaps expired per-game disguised-robot friend // requests via Service.ReapExpiredRobotFriendRequests. It mirrors the account.GuestReaper: // one background goroutine, started once from main. type RobotFriendRequestReaper struct { svc *Service log *zap.Logger } // NewRobotFriendRequestReaper constructs a reaper over svc. log may be nil. func NewRobotFriendRequestReaper(svc *Service, log *zap.Logger) *RobotFriendRequestReaper { if log == nil { log = zap.NewNop() } return &RobotFriendRequestReaper{svc: svc, log: log} } // Interval is the reaper's sweep cadence, for the startup log line. func (r *RobotFriendRequestReaper) Interval() time.Duration { return robotFriendRequestReapInterval } // Retention is the reaper's post-finish retention window, for the startup log line. func (r *RobotFriendRequestReaper) Retention() time.Duration { return robotFriendRequestTTL } // Run reaps expired robot friend requests on each tick until ctx is cancelled. func (r *RobotFriendRequestReaper) Run(ctx context.Context) { ticker := time.NewTicker(robotFriendRequestReapInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: n, err := r.svc.ReapExpiredRobotFriendRequests(ctx) if err != nil { r.log.Warn("robot friend request reap failed", zap.Error(err)) } else if n > 0 { r.log.Info("reaped expired robot friend requests", zap.Int64("count", n)) } } } }