diff --git a/README.zip b/README.zip index 7e3b7f6..3ac9742 100644 Binary files a/README.zip and b/README.zip differ diff --git a/src/Chat/ChatKernel.php b/src/Chat/ChatKernel.php new file mode 100644 index 0000000..7b33aa0 --- /dev/null +++ b/src/Chat/ChatKernel.php @@ -0,0 +1,285 @@ +sessions = $sessionStore ?? new InMemorySessionStore(); + $this->messages = $messageStore ?? new InMemoryMessageStore(); + $this->rooms = $roomStore ?? new InMemoryRoomStore(); + $this->validator = new PayloadValidator(); + $this->presence = new PresenceManager( + new UsernameNormalizer($this->config->maxDisplayNameLength), + $this->sessions, + ); + $this->roomManager = new RoomManager($this->rooms); + $this->directMessages = new DirectMessageRouter($this->roomManager, $this->messages); + $this->privateGroups = new PrivateGroupRouter($this->roomManager, $this->messages); + } + + public function attach(WebSocketServer $server): void + { + $server->on('message', function (Connection $connection, Frame $frame) use ($server): void { + $this->handleMessage($server->connections(), $connection, $frame); + }); + + $server->on('close', function (Connection $connection) use ($server): void { + $this->handleClose($server->connections(), $connection); + }); + } + + public function presence(): PresenceManager + { + return $this->presence; + } + + public function messageStore(): MessageStoreInterface + { + return $this->messages; + } + + public function roomStore(): RoomStoreInterface + { + return $this->rooms; + } + + public function handleMessage( + ConnectionRegistryInterface $connections, + Connection $connection, + Frame $frame, + ): void { + if ($frame->opcode !== Opcode::TEXT) { + $this->sendError($connection, 'Only text frames are supported by the chat core.'); + return; + } + + try { + $envelope = MessageEnvelope::fromJson($frame->payload); + $this->validator->assertEnvelope($envelope); + + match ($envelope->type) { + 'auth.join' => $this->handleJoin($connections, $connection, $envelope), + 'message.global' => $this->handleGlobalMessage($connections, $connection, $envelope), + 'message.direct' => $this->handleDirectMessage($connections, $connection, $envelope), + 'room.create' => $this->handleRoomCreate($connections, $connection, $envelope), + 'room.message' => $this->handleRoomMessage($connections, $connection, $envelope), + default => throw new InvalidPayloadException('Unsupported message type.'), + }; + } catch (Throwable $exception) { + $this->sendError($connection, $exception->getMessage()); + } + } + + private function handleJoin( + ConnectionRegistryInterface $connections, + Connection $connection, + MessageEnvelope $envelope, + ): void { + $session = $this->presence->join($this->validator->displayName($envelope)); + + $connection->setUserId($session->userId); + $this->roomManager->joinGlobalRoom($session->userId); + + $this->sendEnvelope($connection, MessageEnvelope::server('session.accepted', [ + 'session' => $session->toArray(), + ])); + + $this->sendEnvelope($connection, MessageEnvelope::server('presence.snapshot', [ + 'users' => $this->presence->snapshot(), + ])); + + $this->broadcastAuthenticated($connections, MessageEnvelope::server('presence.user_joined', [ + 'user' => $session->toArray(), + ])); + } + + private function handleGlobalMessage( + ConnectionRegistryInterface $connections, + Connection $connection, + MessageEnvelope $envelope, + ): void { + $fromUserId = $this->requireAuthenticated($connection); + $room = $this->roomManager->ensureGlobalRoom(); + $message = ChatMessage::text($room->id, $fromUserId, $this->validator->text($envelope)); + + $this->messages->save($message); + + $this->broadcastAuthenticated($connections, MessageEnvelope::server('message.received', [ + 'roomId' => $room->id, + 'message' => $message->toArray(), + ])); + } + + private function handleDirectMessage( + ConnectionRegistryInterface $connections, + Connection $connection, + MessageEnvelope $envelope, + ): void { + $fromUserId = $this->requireAuthenticated($connection); + $toUserId = $this->validator->targetUserId($envelope); + + $this->assertOnlineUser($toUserId); + + $message = $this->directMessages->send( + fromUserId: $fromUserId, + toUserId: $toUserId, + text: $this->validator->text($envelope), + ); + + $this->deliverToUsers($connections, [$fromUserId, $toUserId], MessageEnvelope::server('message.received', [ + 'roomId' => $message->roomId, + 'message' => $message->toArray(), + ])); + } + + private function handleRoomCreate( + ConnectionRegistryInterface $connections, + Connection $connection, + MessageEnvelope $envelope, + ): void { + $createdByUserId = $this->requireAuthenticated($connection); + $type = $envelope->payload['type'] ?? null; + + if ($type !== Room::TYPE_PRIVATE_GROUP) { + throw new InvalidPayloadException('Only private group rooms can be created in this phase.'); + } + + $participantUserIds = $this->validator->participantUserIds($envelope); + + foreach ($participantUserIds as $participantUserId) { + $this->assertOnlineUser($participantUserId); + } + + $room = $this->privateGroups->createRoom( + createdByUserId: $createdByUserId, + name: $this->validator->roomName($envelope), + participantUserIds: $participantUserIds, + maxMembers: $this->config->maxPrivateGroupMembers, + ); + + $this->deliverToUsers($connections, $room->memberUserIds, MessageEnvelope::server('room.created', [ + 'room' => $room->toArray(), + ])); + } + + private function handleRoomMessage( + ConnectionRegistryInterface $connections, + Connection $connection, + MessageEnvelope $envelope, + ): void { + $fromUserId = $this->requireAuthenticated($connection); + $roomId = $this->validator->roomId($envelope); + $room = $this->roomManager->assertMember($roomId, $fromUserId); + $message = $this->privateGroups->send($roomId, $fromUserId, $this->validator->text($envelope)); + + $this->deliverToUsers($connections, $room->memberUserIds, MessageEnvelope::server('message.received', [ + 'roomId' => $room->id, + 'message' => $message->toArray(), + ])); + } + + private function handleClose(ConnectionRegistryInterface $connections, Connection $connection): void + { + $userId = $connection->userId(); + + if ($userId === null) { + return; + } + + $this->presence->leave($userId); + + $this->broadcastAuthenticated($connections, MessageEnvelope::server('presence.user_left', [ + 'userId' => $userId, + ])); + } + + private function requireAuthenticated(Connection $connection): string + { + $userId = $connection->userId(); + + if ($userId === null) { + throw new InvalidPayloadException('Connection is not authenticated.'); + } + + return $userId; + } + + private function assertOnlineUser(string $userId): void + { + $session = $this->sessions->findByUserId($userId); + + if (!$session instanceof UserSession || !$session->connected) { + throw new InvalidPayloadException('Target user is not online.'); + } + } + + private function sendError(Connection $connection, string $message): void + { + $this->sendEnvelope($connection, MessageEnvelope::server('error', [ + 'message' => $message, + ])); + } + + private function sendEnvelope(Connection $connection, MessageEnvelope $envelope): void + { + $connection->send($envelope->toJson()); + } + + private function broadcastAuthenticated(ConnectionRegistryInterface $connections, MessageEnvelope $envelope): void + { + foreach ($connections->all() as $connection) { + if ($connection->userId() !== null) { + $this->sendEnvelope($connection, $envelope); + } + } + } + + /** + * @param list $userIds + */ + private function deliverToUsers( + ConnectionRegistryInterface $connections, + array $userIds, + MessageEnvelope $envelope, + ): void { + foreach ($connections->all() as $connection) { + $connectionUserId = $connection->userId(); + + if ($connectionUserId !== null && in_array($connectionUserId, $userIds, true)) { + $this->sendEnvelope($connection, $envelope); + } + } + } +} diff --git a/src/Chat/ChatMessage.php b/src/Chat/ChatMessage.php new file mode 100644 index 0000000..20fa6a5 --- /dev/null +++ b/src/Chat/ChatMessage.php @@ -0,0 +1,52 @@ + $metadata + */ + public function __construct( + public string $id, + public string $roomId, + public string $fromUserId, + public string $kind, + public ?string $body, + public DateTimeImmutable $createdAt, + public array $metadata = [], + ) { + } + + public static function text(string $roomId, string $fromUserId, string $text): self + { + return new self( + id: 'msg_' . bin2hex(random_bytes(16)), + roomId: $roomId, + fromUserId: $fromUserId, + kind: 'text', + body: $text, + createdAt: new DateTimeImmutable(), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'roomId' => $this->roomId, + 'fromUserId' => $this->fromUserId, + 'kind' => $this->kind, + 'body' => $this->body, + 'metadata' => $this->metadata, + 'createdAt' => $this->createdAt->format(DATE_ATOM), + ]; + } +} diff --git a/src/Chat/ChatServer.php b/src/Chat/ChatServer.php new file mode 100644 index 0000000..037eca9 --- /dev/null +++ b/src/Chat/ChatServer.php @@ -0,0 +1,54 @@ +kernel->attach($this->server); + } + + public static function create(ServerConfig $serverConfig, ChatConfig $chatConfig): self + { + return new self( + server: new WebSocketServer($serverConfig), + kernel: new ChatKernel($chatConfig), + ); + } + + public function on(string $eventName, callable $listener): self + { + $this->server->on($eventName, $listener); + + return $this; + } + + public function run(): void + { + $this->server->run(); + } + + public function stop(): void + { + $this->server->stop(); + } + + public function webSocketServer(): WebSocketServer + { + return $this->server; + } + + public function kernel(): ChatKernel + { + return $this->kernel; + } +} diff --git a/src/Chat/DirectMessageRouter.php b/src/Chat/DirectMessageRouter.php new file mode 100644 index 0000000..7480f10 --- /dev/null +++ b/src/Chat/DirectMessageRouter.php @@ -0,0 +1,26 @@ +rooms->createDirectRoom($fromUserId, $toUserId); + $message = ChatMessage::text($room->id, $fromUserId, $text); + + $this->messages->save($message); + + return $message; + } +} diff --git a/src/Chat/MessageEnvelope.php b/src/Chat/MessageEnvelope.php new file mode 100644 index 0000000..37be1c9 --- /dev/null +++ b/src/Chat/MessageEnvelope.php @@ -0,0 +1,112 @@ + $payload + * @param array $meta + */ + public function __construct( + public string $type, + public array $payload = [], + public array $meta = [], + ?string $id = null, + ) { + if ($this->type === '') { + throw new InvalidPayloadException('Message type cannot be empty.'); + } + + $this->id = $id ?? self::generateId(); + } + + /** + * @param array $payload + */ + public static function server(string $type, array $payload = []): self + { + return new self($type, $payload); + } + + public static function fromJson(string $json): self + { + try { + $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new InvalidPayloadException('Invalid JSON payload.', previous: $exception); + } + + if (!is_array($decoded)) { + throw new InvalidPayloadException('Message payload must be a JSON object.'); + } + + $type = $decoded['type'] ?? null; + + if (!is_string($type) || trim($type) === '') { + throw new InvalidPayloadException('Message type is required.'); + } + + $payload = self::objectValue($decoded['payload'] ?? []); + $meta = self::objectValue($decoded['meta'] ?? []); + $id = $decoded['id'] ?? null; + + return new self( + type: trim($type), + payload: $payload, + meta: $meta, + id: is_string($id) && $id !== '' ? $id : null, + ); + } + + public function toJson(): string + { + try { + return json_encode($this->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } catch (JsonException $exception) { + throw new InvalidPayloadException('Failed to encode message payload.', previous: $exception); + } + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'payload' => $this->payload, + 'meta' => $this->meta, + ]; + } + + /** + * @return array + */ + private static function objectValue(mixed $value): array + { + if ($value === null) { + return []; + } + + if (!is_array($value)) { + throw new InvalidPayloadException('Message payload fields must be JSON objects.'); + } + + /** @var array $value */ + return $value; + } + + private static function generateId(): string + { + return 'evt_' . bin2hex(random_bytes(16)); + } +} diff --git a/src/Chat/PayloadValidator.php b/src/Chat/PayloadValidator.php new file mode 100644 index 0000000..192cc56 --- /dev/null +++ b/src/Chat/PayloadValidator.php @@ -0,0 +1,116 @@ + + */ + private array $allowedTypes = [ + 'auth.join', + 'message.global', + 'message.direct', + 'room.create', + 'room.message', + ]; + + public function assertEnvelope(MessageEnvelope $envelope): void + { + if (!in_array($envelope->type, $this->allowedTypes, true)) { + throw new InvalidPayloadException('Unsupported message type.'); + } + } + + public function displayName(MessageEnvelope $envelope): string + { + return $this->requiredString($envelope, 'displayName'); + } + + public function text(MessageEnvelope $envelope): string + { + $value = $envelope->payload['text'] ?? null; + + if (!is_string($value)) { + throw new InvalidPayloadException('Payload field text is required.'); + } + + $text = trim($value); + + if ($text === '') { + throw new InvalidPayloadException('Message text cannot be empty.'); + } + + return $text; + } + + public function targetUserId(MessageEnvelope $envelope): string + { + return $this->requiredString($envelope, 'toUserId'); + } + + public function roomId(MessageEnvelope $envelope): string + { + return $this->requiredString($envelope, 'roomId'); + } + + public function roomName(MessageEnvelope $envelope): ?string + { + $name = $envelope->payload['name'] ?? null; + + if ($name === null) { + return null; + } + + if (!is_string($name)) { + throw new InvalidPayloadException('Room name must be a string.'); + } + + return trim($name); + } + + /** + * @return list + */ + public function participantUserIds(MessageEnvelope $envelope): array + { + $value = $envelope->payload['participantUserIds'] ?? null; + + if (!is_array($value)) { + throw new InvalidPayloadException('Private room participants are required.'); + } + + $userIds = []; + + foreach ($value as $item) { + if (!is_string($item) || trim($item) === '') { + throw new InvalidPayloadException('Participant user ids must be non-empty strings.'); + } + + $userIds[] = trim($item); + } + + $userIds = array_values(array_unique($userIds)); + + if ($userIds === []) { + throw new InvalidPayloadException('Private room requires at least one participant.'); + } + + return $userIds; + } + + private function requiredString(MessageEnvelope $envelope, string $key): string + { + $value = $envelope->payload[$key] ?? null; + + if (!is_string($value) || trim($value) === '') { + throw new InvalidPayloadException("Payload field {$key} is required."); + } + + return trim($value); + } +} diff --git a/src/Chat/PresenceManager.php b/src/Chat/PresenceManager.php new file mode 100644 index 0000000..60a67b0 --- /dev/null +++ b/src/Chat/PresenceManager.php @@ -0,0 +1,57 @@ +normalizer->displayName($displayName); + $normalizedKey = $this->normalizer->key($normalizedDisplayName); + + if ($this->sessions->findConnectedByNormalizedDisplayName($normalizedKey) instanceof UserSession) { + throw new UsernameAlreadyTakenException('This display name is already in use.'); + } + + $session = UserSession::create($normalizedDisplayName, $normalizedKey); + + $this->sessions->save($session); + + return $session; + } + + public function leave(string $userId): void + { + $this->sessions->disconnect($userId); + } + + /** + * @return list + */ + public function connectedSessions(): array + { + return $this->sessions->connected(); + } + + /** + * @return list> + */ + public function snapshot(): array + { + return array_map( + static fn (UserSession $session): array => $session->toArray(), + $this->connectedSessions(), + ); + } +} diff --git a/src/Chat/PrivateGroupRouter.php b/src/Chat/PrivateGroupRouter.php new file mode 100644 index 0000000..c4060e8 --- /dev/null +++ b/src/Chat/PrivateGroupRouter.php @@ -0,0 +1,43 @@ + $participantUserIds + */ + public function createRoom( + string $createdByUserId, + ?string $name, + array $participantUserIds, + int $maxMembers, + ): Room { + return $this->rooms->createPrivateGroupRoom( + createdByUserId: $createdByUserId, + name: $name, + participantUserIds: $participantUserIds, + maxMembers: $maxMembers, + ); + } + + public function send(string $roomId, string $fromUserId, string $text): ChatMessage + { + $room = $this->rooms->assertMember($roomId, $fromUserId); + $message = ChatMessage::text($room->id, $fromUserId, $text); + + $this->messages->save($message); + + return $message; + } +} diff --git a/src/Chat/Room.php b/src/Chat/Room.php new file mode 100644 index 0000000..4b6a190 --- /dev/null +++ b/src/Chat/Room.php @@ -0,0 +1,130 @@ + $memberUserIds + */ + public function __construct( + public string $id, + public string $type, + public ?string $name, + public string $createdBy, + public array $memberUserIds, + public DateTimeImmutable $createdAt, + ) { + } + + public static function global(): self + { + return new self( + id: 'global', + type: self::TYPE_GLOBAL, + name: 'Global', + createdBy: 'system', + memberUserIds: [], + createdAt: new DateTimeImmutable(), + ); + } + + /** + * @param list $memberUserIds + */ + public static function direct(string $id, array $memberUserIds, string $createdBy): self + { + return new self( + id: $id, + type: self::TYPE_DIRECT, + name: null, + createdBy: $createdBy, + memberUserIds: $memberUserIds, + createdAt: new DateTimeImmutable(), + ); + } + + /** + * @param list $memberUserIds + */ + public static function privateGroup(string $name, string $createdBy, array $memberUserIds): self + { + return new self( + id: 'room_' . bin2hex(random_bytes(16)), + type: self::TYPE_PRIVATE_GROUP, + name: $name !== '' ? $name : null, + createdBy: $createdBy, + memberUserIds: $memberUserIds, + createdAt: new DateTimeImmutable(), + ); + } + + public function hasMember(string $userId): bool + { + if ($this->type === self::TYPE_GLOBAL) { + return true; + } + + return in_array($userId, $this->memberUserIds, true); + } + + public function withMember(string $userId): self + { + if ($this->hasMember($userId) && $this->type !== self::TYPE_GLOBAL) { + return $this; + } + + $memberUserIds = $this->memberUserIds; + + if (!in_array($userId, $memberUserIds, true)) { + $memberUserIds[] = $userId; + } + + return new self( + id: $this->id, + type: $this->type, + name: $this->name, + createdBy: $this->createdBy, + memberUserIds: $memberUserIds, + createdAt: $this->createdAt, + ); + } + + public function withoutMember(string $userId): self + { + return new self( + id: $this->id, + type: $this->type, + name: $this->name, + createdBy: $this->createdBy, + memberUserIds: array_values(array_filter( + $this->memberUserIds, + static fn (string $memberUserId): bool => $memberUserId !== $userId, + )), + createdAt: $this->createdAt, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'name' => $this->name, + 'createdBy' => $this->createdBy, + 'memberUserIds' => $this->memberUserIds, + 'createdAt' => $this->createdAt->format(DATE_ATOM), + ]; + } +} diff --git a/src/Chat/RoomManager.php b/src/Chat/RoomManager.php new file mode 100644 index 0000000..4462dba --- /dev/null +++ b/src/Chat/RoomManager.php @@ -0,0 +1,111 @@ +rooms->find('global'); + + if ($existing instanceof Room) { + return $existing; + } + + $room = Room::global(); + + $this->rooms->save($room); + + return $room; + } + + public function joinGlobalRoom(string $userId): Room + { + $room = $this->ensureGlobalRoom(); + $this->rooms->addMember($room->id, $userId); + + return $this->rooms->find($room->id) ?? $room; + } + + public function createDirectRoom(string $firstUserId, string $secondUserId): Room + { + if ($firstUserId === $secondUserId) { + throw new InvalidPayloadException('Direct room requires two different users.'); + } + + $memberUserIds = [$firstUserId, $secondUserId]; + sort($memberUserIds); + + $roomId = 'direct_' . sha1($memberUserIds[0] . '|' . $memberUserIds[1]); + $existing = $this->rooms->find($roomId); + + if ($existing instanceof Room) { + return $existing; + } + + $room = Room::direct($roomId, $memberUserIds, $firstUserId); + + $this->rooms->save($room); + + return $room; + } + + /** + * @param list $participantUserIds + */ + public function createPrivateGroupRoom( + string $createdByUserId, + ?string $name, + array $participantUserIds, + int $maxMembers, + ): Room { + $memberUserIds = array_values(array_unique([$createdByUserId, ...$participantUserIds])); + + if (count($memberUserIds) < 2) { + throw new InvalidPayloadException('Private group room requires at least one participant.'); + } + + if (count($memberUserIds) > $maxMembers) { + throw new InvalidPayloadException('Private group room member limit exceeded.'); + } + + $room = Room::privateGroup($name ?? '', $createdByUserId, $memberUserIds); + + $this->rooms->save($room); + + return $room; + } + + public function assertMember(string $roomId, string $userId): Room + { + $room = $this->rooms->find($roomId); + + if (!$room instanceof Room) { + throw new InvalidPayloadException('Room not found.'); + } + + if (!$room->hasMember($userId)) { + throw new RoomAccessDeniedException('User is not a member of this room.'); + } + + return $room; + } + + /** + * @return list + */ + public function visibleForUser(string $userId): array + { + return $this->rooms->visibleForUser($userId); + } +} diff --git a/src/Chat/UserSession.php b/src/Chat/UserSession.php new file mode 100644 index 0000000..1609411 --- /dev/null +++ b/src/Chat/UserSession.php @@ -0,0 +1,57 @@ +connected = false; + $this->lastSeenAt = new DateTimeImmutable(); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'sessionId' => $this->sessionId, + 'userId' => $this->userId, + 'displayName' => $this->displayName, + 'connected' => $this->connected, + 'connectedAt' => $this->connectedAt->format(DATE_ATOM), + 'lastSeenAt' => $this->lastSeenAt->format(DATE_ATOM), + ]; + } +} diff --git a/src/Chat/UsernameNormalizer.php b/src/Chat/UsernameNormalizer.php new file mode 100644 index 0000000..a484441 --- /dev/null +++ b/src/Chat/UsernameNormalizer.php @@ -0,0 +1,34 @@ + $this->maxLength) { + throw new InvalidPayloadException('Display name is too long.'); + } + + return $normalized; + } + + public function key(string $displayName): string + { + return strtolower($this->displayName($displayName)); + } +} diff --git a/src/Contracts/MessageStoreInterface.php b/src/Contracts/MessageStoreInterface.php new file mode 100644 index 0000000..684a473 --- /dev/null +++ b/src/Contracts/MessageStoreInterface.php @@ -0,0 +1,17 @@ + + */ + public function messagesForRoom(string $roomId, int $limit = 50): array; +} diff --git a/src/Contracts/PresenceStoreInterface.php b/src/Contracts/PresenceStoreInterface.php new file mode 100644 index 0000000..8beff96 --- /dev/null +++ b/src/Contracts/PresenceStoreInterface.php @@ -0,0 +1,15 @@ + + */ + public function onlineUserIds(): array; +} diff --git a/src/Contracts/RoomStoreInterface.php b/src/Contracts/RoomStoreInterface.php new file mode 100644 index 0000000..6d27a2a --- /dev/null +++ b/src/Contracts/RoomStoreInterface.php @@ -0,0 +1,28 @@ + + */ + public function all(): array; + + /** + * @return list + */ + public function visibleForUser(string $userId): array; + + public function addMember(string $roomId, string $userId): void; + + public function removeMember(string $roomId, string $userId): void; +} diff --git a/src/Contracts/SessionStoreInterface.php b/src/Contracts/SessionStoreInterface.php new file mode 100644 index 0000000..5853a87 --- /dev/null +++ b/src/Contracts/SessionStoreInterface.php @@ -0,0 +1,25 @@ + + */ + public function connected(): array; + + public function disconnect(string $userId): void; +} diff --git a/src/Exceptions/InvalidPayloadException.php b/src/Exceptions/InvalidPayloadException.php new file mode 100644 index 0000000..9824ed7 --- /dev/null +++ b/src/Exceptions/InvalidPayloadException.php @@ -0,0 +1,11 @@ +> + */ + private array $messagesByRoomId = []; + + public function save(ChatMessage $message): void + { + $this->messagesByRoomId[$message->roomId] ??= []; + $this->messagesByRoomId[$message->roomId][] = $message; + } + + public function messagesForRoom(string $roomId, int $limit = 50): array + { + $messages = $this->messagesByRoomId[$roomId] ?? []; + + if ($limit < 1) { + return []; + } + + return array_slice($messages, -$limit); + } +} diff --git a/src/Storage/InMemory/InMemoryRoomStore.php b/src/Storage/InMemory/InMemoryRoomStore.php new file mode 100644 index 0000000..ea57310 --- /dev/null +++ b/src/Storage/InMemory/InMemoryRoomStore.php @@ -0,0 +1,61 @@ + + */ + private array $roomsById = []; + + public function save(Room $room): void + { + $this->roomsById[$room->id] = $room; + } + + public function find(string $roomId): ?Room + { + return $this->roomsById[$roomId] ?? null; + } + + public function all(): array + { + return array_values($this->roomsById); + } + + public function visibleForUser(string $userId): array + { + return array_values(array_filter( + $this->roomsById, + static fn (Room $room): bool => $room->hasMember($userId), + )); + } + + public function addMember(string $roomId, string $userId): void + { + $room = $this->find($roomId); + + if (!$room instanceof Room) { + return; + } + + $this->save($room->withMember($userId)); + } + + public function removeMember(string $roomId, string $userId): void + { + $room = $this->find($roomId); + + if (!$room instanceof Room) { + return; + } + + $this->save($room->withoutMember($userId)); + } +} diff --git a/src/Storage/InMemory/InMemorySessionStore.php b/src/Storage/InMemory/InMemorySessionStore.php new file mode 100644 index 0000000..a2ee7b4 --- /dev/null +++ b/src/Storage/InMemory/InMemorySessionStore.php @@ -0,0 +1,87 @@ + + */ + private array $sessionsById = []; + + /** + * @var array + */ + private array $sessionIdsByUserId = []; + + public function save(UserSession $session): void + { + $this->sessionsById[$session->sessionId] = $session; + $this->sessionIdsByUserId[$session->userId] = $session->sessionId; + } + + public function findByUserId(string $userId): ?UserSession + { + $sessionId = $this->sessionIdsByUserId[$userId] ?? null; + + if ($sessionId === null) { + return null; + } + + return $this->findBySessionId($sessionId); + } + + public function findBySessionId(string $sessionId): ?UserSession + { + return $this->sessionsById[$sessionId] ?? null; + } + + public function findConnectedByNormalizedDisplayName(string $normalizedDisplayName): ?UserSession + { + foreach ($this->sessionsById as $session) { + if ($session->connected && $session->normalizedDisplayName === $normalizedDisplayName) { + return $session; + } + } + + return null; + } + + public function connected(): array + { + return array_values(array_filter( + $this->sessionsById, + static fn (UserSession $session): bool => $session->connected, + )); + } + + public function disconnect(string $userId): void + { + $session = $this->findByUserId($userId); + + if ($session instanceof UserSession) { + $session->disconnect(); + } + } + + public function isOnline(string $userId): bool + { + $session = $this->findByUserId($userId); + + return $session instanceof UserSession && $session->connected; + } + + public function onlineUserIds(): array + { + return array_map( + static fn (UserSession $session): string => $session->userId, + $this->connected(), + ); + } +} diff --git a/tests/Integration/Chat/ChatServerTest.php b/tests/Integration/Chat/ChatServerTest.php new file mode 100644 index 0000000..13c7a4b --- /dev/null +++ b/tests/Integration/Chat/ChatServerTest.php @@ -0,0 +1,169 @@ + + */ + private array $sockets = []; + + protected function tearDown(): void + { + foreach ($this->sockets as $socket) { + socket_close($socket); + } + + $this->sockets = []; + } + + public function testAuthJoinCreatesUserSession(): void + { + $server = ChatServer::create(ServerConfig::new(), ChatConfig::new()); + $connection = $this->registeredConnection($server, 'conn_william'); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'auth.join', + 'payload' => [ + 'displayName' => 'William', + ], + ]); + + self::assertNotNull($connection->userId()); + self::assertSame(1, count($server->kernel()->presence()->connectedSessions())); + } + + public function testDuplicatedDisplayNameIsRejected(): void + { + $server = ChatServer::create(ServerConfig::new(), ChatConfig::new()); + $firstConnection = $this->registeredConnection($server, 'conn_first'); + $secondConnection = $this->registeredConnection($server, 'conn_second'); + + $this->dispatchClientMessage($server, $firstConnection, [ + 'type' => 'auth.join', + 'payload' => [ + 'displayName' => 'William', + ], + ]); + + $this->dispatchClientMessage($server, $secondConnection, [ + 'type' => 'auth.join', + 'payload' => [ + 'displayName' => 'william', + ], + ]); + + self::assertNotNull($firstConnection->userId()); + self::assertNull($secondConnection->userId()); + self::assertSame(1, count($server->kernel()->presence()->connectedSessions())); + } + + public function testAuthenticatedUserCanSendGlobalMessage(): void + { + $server = ChatServer::create(ServerConfig::new(), ChatConfig::new()); + $connection = $this->registeredConnection($server, 'conn_william'); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'auth.join', + 'payload' => [ + 'displayName' => 'William', + ], + ]); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'message.global', + 'payload' => [ + 'text' => 'Hello world', + ], + ]); + + $messages = $server->kernel()->messageStore()->messagesForRoom('global'); + + self::assertSame(1, count($messages)); + self::assertSame('Hello world', $messages[0]->body); + } + + /** + * @param array $message + */ + private function dispatchClientMessage(ChatServer $server, Connection $connection, array $message): void + { + $json = json_encode($message, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + $server->webSocketServer()->dispatcher()->dispatch( + new MessageReceived($connection, Frame::text($json)), + ); + } + + private function registeredConnection(ChatServer $server, string $id): Connection + { + [, $peerSocket] = $this->connectedSocketPair(); + + $connection = new Connection($id, $peerSocket, new FrameCodec()); + + $server->webSocketServer()->connections()->add($connection); + + return $connection; + } + + /** + * @return array{0: Socket, 1: Socket} + */ + private function connectedSocketPair(): array + { + $serverSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $clientSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + + if ($serverSocket === false || $clientSocket === false) { + throw new RuntimeException('Failed to create test sockets.'); + } + + $this->sockets[] = $serverSocket; + $this->sockets[] = $clientSocket; + + socket_set_option($serverSocket, SOL_SOCKET, SO_REUSEADDR, 1); + + if (!socket_bind($serverSocket, '127.0.0.1', 0)) { + throw new RuntimeException('Failed to bind test server socket.'); + } + + if (!socket_listen($serverSocket, 1)) { + throw new RuntimeException('Failed to listen on test server socket.'); + } + + $address = ''; + $port = 0; + + if (!socket_getsockname($serverSocket, $address, $port)) { + throw new RuntimeException('Failed to read test server socket address.'); + } + + if (!socket_connect($clientSocket, $address, $port)) { + throw new RuntimeException('Failed to connect test client socket.'); + } + + $peerSocket = socket_accept($serverSocket); + + if ($peerSocket === false) { + throw new RuntimeException('Failed to accept test socket connection.'); + } + + $this->sockets[] = $peerSocket; + + return [$clientSocket, $peerSocket]; + } +} diff --git a/tests/Unit/Chat/PayloadValidatorTest.php b/tests/Unit/Chat/PayloadValidatorTest.php new file mode 100644 index 0000000..98bf375 --- /dev/null +++ b/tests/Unit/Chat/PayloadValidatorTest.php @@ -0,0 +1,53 @@ + 'William']); + + self::assertSame('William', $validator->displayName($envelope)); + } + + public function testEmptyMessageTextIsRejected(): void + { + $validator = new PayloadValidator(); + $envelope = new MessageEnvelope('message.global', ['text' => ' ']); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Message text cannot be empty.'); + + $validator->text($envelope); + } + + public function testParticipantUserIdsAreNormalized(): void + { + $validator = new PayloadValidator(); + $envelope = new MessageEnvelope('room.create', [ + 'participantUserIds' => ['usr_1', 'usr_1', 'usr_2'], + ]); + + self::assertSame(['usr_1', 'usr_2'], $validator->participantUserIds($envelope)); + } + + public function testUnsupportedMessageTypeIsRejected(): void + { + $validator = new PayloadValidator(); + $envelope = new MessageEnvelope('unknown.event'); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Unsupported message type.'); + + $validator->assertEnvelope($envelope); + } +} diff --git a/tests/Unit/Chat/RoomManagerTest.php b/tests/Unit/Chat/RoomManagerTest.php new file mode 100644 index 0000000..f0e5b9f --- /dev/null +++ b/tests/Unit/Chat/RoomManagerTest.php @@ -0,0 +1,88 @@ +ensureGlobalRoom(); + $secondRoom = $manager->ensureGlobalRoom(); + + self::assertSame('global', $firstRoom->id); + self::assertSame($firstRoom, $secondRoom); + self::assertSame(1, count($store->all())); + } + + public function testDirectRoomUsesSameIdForSameUsers(): void + { + $manager = new RoomManager(new InMemoryRoomStore()); + + $firstRoom = $manager->createDirectRoom('usr_a', 'usr_b'); + $secondRoom = $manager->createDirectRoom('usr_b', 'usr_a'); + + self::assertSame($firstRoom->id, $secondRoom->id); + self::assertSame(Room::TYPE_DIRECT, $firstRoom->type); + self::assertSame(['usr_a', 'usr_b'], $firstRoom->memberUserIds); + } + + public function testPrivateGroupRoomIncludesCreator(): void + { + $manager = new RoomManager(new InMemoryRoomStore()); + + $room = $manager->createPrivateGroupRoom( + createdByUserId: 'usr_creator', + name: 'Secret room', + participantUserIds: ['usr_a', 'usr_b'], + maxMembers: 5, + ); + + self::assertSame(Room::TYPE_PRIVATE_GROUP, $room->type); + self::assertSame('Secret room', $room->name); + self::assertSame(['usr_creator', 'usr_a', 'usr_b'], $room->memberUserIds); + } + + public function testPrivateGroupMemberLimitIsValidated(): void + { + $manager = new RoomManager(new InMemoryRoomStore()); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Private group room member limit exceeded.'); + + $manager->createPrivateGroupRoom( + createdByUserId: 'usr_creator', + name: null, + participantUserIds: ['usr_a', 'usr_b'], + maxMembers: 2, + ); + } + + public function testRoomAccessIsValidated(): void + { + $manager = new RoomManager(new InMemoryRoomStore()); + + $room = $manager->createPrivateGroupRoom( + createdByUserId: 'usr_creator', + name: null, + participantUserIds: ['usr_a'], + maxMembers: 5, + ); + + $this->expectException(RoomAccessDeniedException::class); + $this->expectExceptionMessage('User is not a member of this room.'); + + $manager->assertMember($room->id, 'usr_outside'); + } +} diff --git a/tests/Unit/Chat/UsernameNormalizerTest.php b/tests/Unit/Chat/UsernameNormalizerTest.php new file mode 100644 index 0000000..b2f1ceb --- /dev/null +++ b/tests/Unit/Chat/UsernameNormalizerTest.php @@ -0,0 +1,46 @@ +displayName(' Ana Paula ')); + } + + public function testKeyIsCaseInsensitive(): void + { + $normalizer = new UsernameNormalizer(); + + self::assertSame('william', $normalizer->key('William')); + } + + public function testEmptyDisplayNameIsRejected(): void + { + $normalizer = new UsernameNormalizer(); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Display name cannot be empty.'); + + $normalizer->displayName(' '); + } + + public function testTooLongDisplayNameIsRejected(): void + { + $normalizer = new UsernameNormalizer(maxLength: 5); + + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('Display name is too long.'); + + $normalizer->displayName('William'); + } +}