From f1f5d834974d0d2a5323d3c514b9253bf5a25719 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Mon, 26 Jan 2026 14:48:50 +0800 Subject: [PATCH] user status report system --- src/ws/dto/user-status.dto.ts | 16 +++ src/ws/state/state.gateway.spec.ts | 152 +++++++++++++++++++++++++++++ src/ws/state/state.gateway.ts | 62 ++++++++++++ src/ws/state/ws-events.ts | 2 + 4 files changed, 232 insertions(+) create mode 100644 src/ws/dto/user-status.dto.ts diff --git a/src/ws/dto/user-status.dto.ts b/src/ws/dto/user-status.dto.ts new file mode 100644 index 0000000..469e08b --- /dev/null +++ b/src/ws/dto/user-status.dto.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export enum UserState { + IDLE = 'idle', + RESTING = 'resting', +} + +export class UserStatusDto { + @IsString() + @IsNotEmpty() + @MaxLength(100) + activeApp: string; + + @IsEnum(UserState) + state: UserState; +} diff --git a/src/ws/state/state.gateway.spec.ts b/src/ws/state/state.gateway.spec.ts index bb761bf..d773b8a 100644 --- a/src/ws/state/state.gateway.spec.ts +++ b/src/ws/state/state.gateway.spec.ts @@ -10,6 +10,8 @@ import { WsNotificationService } from './ws-notification.service'; import { SendInteractionDto } from '../dto/send-interaction.dto'; import { WsException } from '@nestjs/websockets'; +import { UserStatusDto, UserState } from '../dto/user-status.dto'; + interface MockSocket extends Partial { id: string; data: { @@ -475,6 +477,156 @@ describe('StateGateway', () => { }); }); + describe('handleClientReportUserStatus', () => { + it('should emit user status to connected friends', async () => { + const mockClient: MockSocket = { + id: 'client1', + data: { + user: { keycloakSub: 'test-sub' }, + userId: 'user-1', + activeDollId: 'doll-1', // User must have active doll + friends: new Set(['friend-1']), + }, + }; + + // Mock getFriendsSockets to return the friend's socket + (mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([ + { userId: 'friend-1', socketId: 'friend-socket-id' }, + ]); + + const data: UserStatusDto = { + activeApp: 'VS Code', + state: UserState.IDLE, + }; + + await gateway.handleClientReportUserStatus( + mockClient as unknown as AuthenticatedSocket, + data, + ); + + // Verify that message was emitted via WsNotificationService + expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledWith( + 'friend-socket-id', + 'friend-user-status', + { + userId: 'user-1', + status: data, + }, + ); + }); + + it('should NOT emit if user has no active doll', async () => { + const mockClient: MockSocket = { + id: 'client1', + data: { + user: { keycloakSub: 'test-sub' }, + userId: 'user-1', + activeDollId: null, // No doll + friends: new Set(['friend-1']), + }, + }; + + const data: UserStatusDto = { + activeApp: 'VS Code', + state: UserState.IDLE, + }; + + await gateway.handleClientReportUserStatus( + mockClient as unknown as AuthenticatedSocket, + data, + ); + + expect(mockWsNotificationService.emitToSocket).not.toHaveBeenCalled(); + }); + + it('should return early when userId is missing (not initialized)', async () => { + const mockClient: MockSocket = { + id: 'client1', + data: { + user: { keycloakSub: 'test-sub' }, + // userId is missing + friends: new Set(['friend-1']), + }, + }; + + const data: UserStatusDto = { + activeApp: 'VS Code', + state: UserState.IDLE, + }; + + await gateway.handleClientReportUserStatus( + mockClient as unknown as AuthenticatedSocket, + data, + ); + + // Verify that no message was emitted + expect(mockWsNotificationService.emitToSocket).not.toHaveBeenCalled(); + }); + + it('should throw exception when client is not authenticated', async () => { + const mockClient: MockSocket = { + id: 'client1', + data: {}, + }; + const data: UserStatusDto = { + activeApp: 'VS Code', + state: UserState.IDLE, + }; + + await expect( + gateway.handleClientReportUserStatus( + mockClient as unknown as AuthenticatedSocket, + data, + ), + ).rejects.toThrow('Unauthorized'); + }); + + it('should throttle broadcasts to prevent spam', async () => { + const mockClient: MockSocket = { + id: 'client1', + data: { + user: { keycloakSub: 'test-sub' }, + userId: 'user-1', + activeDollId: 'doll-1', + friends: new Set(['friend-1']), + }, + }; + + // Mock getFriendsSockets to return the friend's socket + (mockUserSocketService.getFriendsSockets as jest.Mock).mockResolvedValue([ + { userId: 'friend-1', socketId: 'friend-socket-id' }, + ]); + + const data: UserStatusDto = { + activeApp: 'VS Code', + state: UserState.IDLE, + }; + + // First call should succeed + await gateway.handleClientReportUserStatus( + mockClient as unknown as AuthenticatedSocket, + data, + ); + + // Second call immediately after should be throttled + await gateway.handleClientReportUserStatus( + mockClient as unknown as AuthenticatedSocket, + data, + ); + + // Verify that message was emitted only once (throttled) + expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledTimes(1); + expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledWith( + 'friend-socket-id', + 'friend-user-status', + { + userId: 'user-1', + status: data, + }, + ); + }); + }); + describe('handleSendInteraction', () => { it('should send interaction to friend if online', async () => { const mockClient: MockSocket = { diff --git a/src/ws/state/state.gateway.ts b/src/ws/state/state.gateway.ts index f678aa9..f4b8327 100644 --- a/src/ws/state/state.gateway.ts +++ b/src/ws/state/state.gateway.ts @@ -18,6 +18,7 @@ import type { AuthenticatedSocket } from '../../types/socket'; import { AuthService } from '../../auth/auth.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { CursorPositionDto } from '../dto/cursor-position.dto'; +import { UserStatusDto } from '../dto/user-status.dto'; import { SendInteractionDto } from '../dto/send-interaction.dto'; import { InteractionPayloadDto } from '../dto/interaction-payload.dto'; import { PrismaService } from '../../database/prisma.service'; @@ -339,6 +340,67 @@ export class StateGateway } } + @SubscribeMessage(WS_EVENT.CLIENT_REPORT_USER_STATUS) + async handleClientReportUserStatus( + client: AuthenticatedSocket, + data: UserStatusDto, + ) { + const user = client.data.user; + + if (!user) { + throw new WsException('Unauthorized'); + } + + const currentUserId = client.data.userId; + + if (!currentUserId) { + // User has not initialized yet + return; + } + + // Do not broadcast user status if user has no active doll + if (!client.data.activeDollId) { + return; + } + + const now = Date.now(); + const lastBroadcast = this.lastBroadcastMap.get(currentUserId) || 0; + if (now - lastBroadcast < 500) { + return; + } + this.lastBroadcastMap.set(currentUserId, now); + + // Broadcast to online friends + const friends = client.data.friends; + if (friends) { + const friendIds = Array.from(friends); + try { + const friendSockets = + await this.userSocketService.getFriendsSockets(friendIds); + + for (const { socketId } of friendSockets) { + const payload = { + userId: currentUserId, + status: data, + }; + this.wsNotificationService.emitToSocket( + socketId, + WS_EVENT.FRIEND_USER_STATUS, + payload, + ); + } + this.logger.debug( + `Broadcasted user status to ${friendSockets.length} friends for user ${currentUserId}`, + ); + } catch (error) { + this.logger.error( + `Failed to broadcast user status for user ${currentUserId}: ${(error as Error).message}`, + (error as Error).stack, + ); + } + } + } + @SubscribeMessage(WS_EVENT.CLIENT_SEND_INTERACTION) async handleSendInteraction( client: AuthenticatedSocket, diff --git a/src/ws/state/ws-events.ts b/src/ws/state/ws-events.ts index 2d79a98..a09e5b9 100644 --- a/src/ws/state/ws-events.ts +++ b/src/ws/state/ws-events.ts @@ -7,6 +7,8 @@ export const WS_EVENT = { FRIEND_REQUEST_DENIED: 'friend-request-denied', UNFRIENDED: 'unfriended', FRIEND_CURSOR_POSITION: 'friend-cursor-position', + CLIENT_REPORT_USER_STATUS: 'client-report-user-status', + FRIEND_USER_STATUS: 'friend-user-status', FRIEND_DISCONNECTED: 'friend-disconnected', FRIEND_DOLL_CREATED: 'friend-doll-created', FRIEND_DOLL_UPDATED: 'friend-doll-updated',