From 51e097d46e48225b78818c91b7a6f3106d2520ea Mon Sep 17 00:00:00 2001 From: Micilini Roll Date: Sat, 9 May 2026 16:36:10 -0300 Subject: [PATCH] phase 04: implement chat core and unique usernames --- README.zip | Bin 64576 -> 86271 bytes src/Chat/ChatKernel.php | 285 ++++++++++++++++++ src/Chat/ChatMessage.php | 52 ++++ src/Chat/ChatServer.php | 54 ++++ src/Chat/DirectMessageRouter.php | 26 ++ src/Chat/MessageEnvelope.php | 112 +++++++ src/Chat/PayloadValidator.php | 116 +++++++ src/Chat/PresenceManager.php | 57 ++++ src/Chat/PrivateGroupRouter.php | 43 +++ src/Chat/Room.php | 130 ++++++++ src/Chat/RoomManager.php | 111 +++++++ src/Chat/UserSession.php | 57 ++++ src/Chat/UsernameNormalizer.php | 34 +++ src/Contracts/MessageStoreInterface.php | 17 ++ src/Contracts/PresenceStoreInterface.php | 15 + src/Contracts/RoomStoreInterface.php | 28 ++ src/Contracts/SessionStoreInterface.php | 25 ++ src/Exceptions/InvalidPayloadException.php | 11 + src/Exceptions/RoomAccessDeniedException.php | 11 + .../UsernameAlreadyTakenException.php | 11 + src/Storage/InMemory/InMemoryMessageStore.php | 33 ++ src/Storage/InMemory/InMemoryRoomStore.php | 61 ++++ src/Storage/InMemory/InMemorySessionStore.php | 87 ++++++ tests/Integration/Chat/ChatServerTest.php | 169 +++++++++++ tests/Unit/Chat/PayloadValidatorTest.php | 53 ++++ tests/Unit/Chat/RoomManagerTest.php | 88 ++++++ tests/Unit/Chat/UsernameNormalizerTest.php | 46 +++ 27 files changed, 1732 insertions(+) create mode 100644 src/Chat/ChatKernel.php create mode 100644 src/Chat/ChatMessage.php create mode 100644 src/Chat/ChatServer.php create mode 100644 src/Chat/DirectMessageRouter.php create mode 100644 src/Chat/MessageEnvelope.php create mode 100644 src/Chat/PayloadValidator.php create mode 100644 src/Chat/PresenceManager.php create mode 100644 src/Chat/PrivateGroupRouter.php create mode 100644 src/Chat/Room.php create mode 100644 src/Chat/RoomManager.php create mode 100644 src/Chat/UserSession.php create mode 100644 src/Chat/UsernameNormalizer.php create mode 100644 src/Contracts/MessageStoreInterface.php create mode 100644 src/Contracts/PresenceStoreInterface.php create mode 100644 src/Contracts/RoomStoreInterface.php create mode 100644 src/Contracts/SessionStoreInterface.php create mode 100644 src/Exceptions/InvalidPayloadException.php create mode 100644 src/Exceptions/RoomAccessDeniedException.php create mode 100644 src/Exceptions/UsernameAlreadyTakenException.php create mode 100644 src/Storage/InMemory/InMemoryMessageStore.php create mode 100644 src/Storage/InMemory/InMemoryRoomStore.php create mode 100644 src/Storage/InMemory/InMemorySessionStore.php create mode 100644 tests/Integration/Chat/ChatServerTest.php create mode 100644 tests/Unit/Chat/PayloadValidatorTest.php create mode 100644 tests/Unit/Chat/RoomManagerTest.php create mode 100644 tests/Unit/Chat/UsernameNormalizerTest.php diff --git a/README.zip b/README.zip index 7e3b7f6ecc28cc8f4a73d35b715ff915f262f9ce..3ac9742050422c0b5b0ab53565f6612c01fba71d 100644 GIT binary patch delta 24856 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 WFsCpp^K4i^7El%uf|B0jl>Q&&O~7*i 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'); + } +}