diff --git a/README.md b/README.md index 74204e3..f26131b 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,38 @@ $pdo = PdoConnectionFactory::sqlite(__DIR__ . '/storage/phpsockets.sqlite'); The CLI migration command will be added in a future phase. +## Bot hooks + +PHPSockets includes a lightweight bot hook layer. + +Bots can listen to text messages and respond in the same conversation context. + +Supported contexts: + +- Global Room +- Direct conversations +- Private group rooms + +Example: + +```php +$server->bots()->register(new EchoBot()); +``` + +Example command: + +```txt +/echo hello +``` + +Response: + +```txt +hello +``` + +Bots are intentionally simple in v1. They do not call external AI APIs or run asynchronous jobs. + ## Emoji and small attachment support The chat examples support a composer action button next to the message input. diff --git a/examples/easy-chat/public/assets/app.js b/examples/easy-chat/public/assets/app.js index 9965c5e..f0e17b6 100644 --- a/examples/easy-chat/public/assets/app.js +++ b/examples/easy-chat/public/assets/app.js @@ -1044,11 +1044,13 @@ function addMessage(message) { } const isOwn = state.currentUser && message.fromUserId === state.currentUser.userId; - const sender = findDisplayName(message.fromUserId); + const sender = displayNameForMessage(message); const createdAt = formatTime(message.createdAt); + const isBot = message.kind === 'bot' || Boolean(message.metadata && message.metadata.bot); const row = document.createElement('div'); row.className = isOwn ? 'message-row is-own' : 'message-row'; + row.classList.toggle('is-bot', isBot); row.dataset.messageId = message.id; const footer = document.createElement('div'); @@ -1157,6 +1159,14 @@ function findDisplayName(userId) { return user.displayName; } +function displayNameForMessage(message) { + if (message && message.metadata && message.metadata.botName) { + return message.metadata.botName; + } + + return findDisplayName(message.fromUserId); +} + function formatTime(value) { if (!value) { return 'now'; diff --git a/examples/easy-chat/public/assets/style.css b/examples/easy-chat/public/assets/style.css index 534dd9b..3055b23 100644 --- a/examples/easy-chat/public/assets/style.css +++ b/examples/easy-chat/public/assets/style.css @@ -368,6 +368,25 @@ body { background: linear-gradient(135deg, rgba(37, 99, 235, 0.95), rgba(8, 145, 178, 0.95)); } +.message-row.is-bot .message-bubble { + border: 1px solid rgba(56, 189, 248, 0.28); + background: rgba(14, 116, 144, 0.18); +} + +.message-row.is-bot .message-meta::before { + content: "BOT"; + display: inline-flex; + align-items: center; + margin-right: 8px; + padding: 2px 7px; + border-radius: 999px; + background: rgba(56, 189, 248, 0.18); + color: #7dd3fc; + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.04em; +} + .typing-indicator { min-height: 42px; padding: 0 24px 16px; diff --git a/examples/medium-chat/public/assets/app.js b/examples/medium-chat/public/assets/app.js index 56dad97..8de2cb1 100644 --- a/examples/medium-chat/public/assets/app.js +++ b/examples/medium-chat/public/assets/app.js @@ -1091,11 +1091,13 @@ function addMessage(message) { } const isOwn = state.currentUser && message.fromUserId === state.currentUser.userId; - const sender = findDisplayName(message.fromUserId); + const sender = displayNameForMessage(message); const createdAt = formatTime(message.createdAt); + const isBot = message.kind === 'bot' || Boolean(message.metadata && message.metadata.bot); const row = document.createElement('div'); row.className = isOwn ? 'message-row is-own' : 'message-row'; + row.classList.toggle('is-bot', isBot); row.dataset.messageId = message.id; const footer = document.createElement('div'); @@ -1204,6 +1206,14 @@ function findDisplayName(userId) { return user.displayName; } +function displayNameForMessage(message) { + if (message && message.metadata && message.metadata.botName) { + return message.metadata.botName; + } + + return findDisplayName(message.fromUserId); +} + function formatTime(value) { if (!value) { return 'now'; diff --git a/examples/medium-chat/public/assets/style.css b/examples/medium-chat/public/assets/style.css index dab3a5c..7526457 100644 --- a/examples/medium-chat/public/assets/style.css +++ b/examples/medium-chat/public/assets/style.css @@ -386,6 +386,25 @@ body { background: linear-gradient(135deg, rgba(109, 40, 217, 0.96), rgba(8, 145, 178, 0.96)); } +.message-row.is-bot .message-bubble { + border: 1px solid rgba(56, 189, 248, 0.28); + background: rgba(14, 116, 144, 0.18); +} + +.message-row.is-bot .message-meta::before { + content: "BOT"; + display: inline-flex; + align-items: center; + margin-right: 8px; + padding: 2px 7px; + border-radius: 999px; + background: rgba(56, 189, 248, 0.18); + color: #7dd3fc; + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.04em; +} + .typing-indicator { min-height: 42px; padding: 0 22px 16px; diff --git a/examples/private-chat/README.md b/examples/private-chat/README.md index beeebda..4e9dc4b 100644 --- a/examples/private-chat/README.md +++ b/examples/private-chat/README.md @@ -112,6 +112,23 @@ PrivateChat displays unread badges for Global Room, direct conversations and pri Badges increase while a conversation is not open and reset when the conversation is opened. +## Bot commands + +This example registers two simple bots: + +```txt +/help +/echo +``` + +The bot response appears in the same conversation: + +- Global Room +- Direct conversation +- Private group room + +Bots only respond to text messages. File messages do not trigger bots. + ## Storage note This example still uses in-memory storage by default. diff --git a/examples/private-chat/bots/EchoBot.php b/examples/private-chat/bots/EchoBot.php new file mode 100644 index 0000000..efe6932 --- /dev/null +++ b/examples/private-chat/bots/EchoBot.php @@ -0,0 +1,34 @@ +text()); + + if (!str_starts_with($text, '/echo ')) { + return null; + } + + $message = trim(substr($text, 6)); + + if ($message === '') { + return BotResponse::text('Usage: /echo '); + } + + return BotResponse::text($message); + } +} diff --git a/examples/private-chat/bots/HelpBot.php b/examples/private-chat/bots/HelpBot.php new file mode 100644 index 0000000..01b2486 --- /dev/null +++ b/examples/private-chat/bots/HelpBot.php @@ -0,0 +1,26 @@ +text()) !== '/help') { + return null; + } + + return BotResponse::text('Available commands: /help, /echo '); + } +} diff --git a/examples/private-chat/public/assets/app.js b/examples/private-chat/public/assets/app.js index 01fdbc2..282393c 100644 --- a/examples/private-chat/public/assets/app.js +++ b/examples/private-chat/public/assets/app.js @@ -869,12 +869,24 @@ function conversationIdForMessage(message) { return 'global'; } + if (message.roomId) { + for (const conversation of state.conversations.values()) { + if (conversation.roomId === message.roomId) { + return conversation.id; + } + } + } + const roomConversationId = groupConversationId(message.roomId); if (state.conversations.has(roomConversationId)) { return roomConversationId; } + if (message.kind === 'bot' || (message.metadata && message.metadata.bot)) { + return state.activeConversationId || 'global'; + } + if (state.currentUser && message.fromUserId !== state.currentUser.userId) { ensureDirectConversationFromUserId(message.fromUserId); return directConversationId(message.fromUserId); @@ -1581,11 +1593,13 @@ function appendMessageElement(message) { } const isOwn = state.currentUser && message.fromUserId === state.currentUser.userId; - const sender = findDisplayName(message.fromUserId); + const sender = displayNameForMessage(message); const createdAt = formatTime(message.createdAt); + const isBot = message.kind === 'bot' || Boolean(message.metadata && message.metadata.bot); const row = document.createElement('div'); row.className = isOwn ? 'message-row is-own' : 'message-row'; + row.classList.toggle('is-bot', isBot); row.dataset.messageId = message.id; const footer = document.createElement('div'); @@ -1762,6 +1776,14 @@ function findDisplayName(userId) { return user.displayName; } +function displayNameForMessage(message) { + if (message && message.metadata && message.metadata.botName) { + return message.metadata.botName; + } + + return findDisplayName(message.fromUserId); +} + function formatTime(value) { if (!value) { return 'now'; diff --git a/examples/private-chat/public/assets/style.css b/examples/private-chat/public/assets/style.css index 6bd4686..a8c15f4 100644 --- a/examples/private-chat/public/assets/style.css +++ b/examples/private-chat/public/assets/style.css @@ -447,6 +447,25 @@ body { background: linear-gradient(135deg, rgba(5, 150, 105, 0.96), rgba(37, 99, 235, 0.96)); } +.message-row.is-bot .message-bubble { + border: 1px solid rgba(56, 189, 248, 0.28); + background: rgba(14, 116, 144, 0.18); +} + +.message-row.is-bot .message-meta::before { + content: "BOT"; + display: inline-flex; + align-items: center; + margin-right: 8px; + padding: 2px 7px; + border-radius: 999px; + background: rgba(56, 189, 248, 0.18); + color: #7dd3fc; + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.04em; +} + .typing-indicator { min-height: 42px; padding: 0 22px 16px; diff --git a/examples/private-chat/server.php b/examples/private-chat/server.php index 8ec7a27..1b115da 100644 --- a/examples/private-chat/server.php +++ b/examples/private-chat/server.php @@ -3,6 +3,8 @@ declare(strict_types=1); require __DIR__ . '/../../vendor/autoload.php'; +require __DIR__ . '/bots/EchoBot.php'; +require __DIR__ . '/bots/HelpBot.php'; use Micilini\PhpSockets\Chat\ChatMessage; use Micilini\PhpSockets\Chat\ChatServer; @@ -10,6 +12,8 @@ use Micilini\PhpSockets\Config\ChatConfig; use Micilini\PhpSockets\Config\ServerConfig; use Micilini\PhpSockets\Connection\Connection; +use Micilini\PhpSockets\Examples\PrivateChat\Bots\EchoBot; +use Micilini\PhpSockets\Examples\PrivateChat\Bots\HelpBot; $host = getenv('PHPSOCKETS_HOST') ?: '127.0.0.1'; $port = (int) (getenv('PHPSOCKETS_PORT') ?: 8080); @@ -23,6 +27,10 @@ ChatConfig::new(), ); +$server->bots() + ->register(new HelpBot()) + ->register(new EchoBot()); + $server->on('open', function (Connection $connection): void { echo "[socket.open] {$connection->id()} connected from {$connection->remoteAddress()}\n"; }); @@ -80,4 +88,15 @@ echo "[private.message.received] scope={$scope} room={$message->roomId} from={$message->fromUserId}: {$body}\n"; }); +$server->on('bot.responded', function (array $event): void { + $message = $event['message'] ?? null; + $scope = (string) ($event['scope'] ?? 'unknown'); + + if (!$message instanceof ChatMessage) { + return; + } + + echo "[private.bot.responded] scope={$scope} room={$message->roomId} from={$message->fromUserId}\n"; +}); + $server->run(); diff --git a/src/Chat/Bot/BotContext.php b/src/Chat/Bot/BotContext.php new file mode 100644 index 0000000..4243cfe --- /dev/null +++ b/src/Chat/Bot/BotContext.php @@ -0,0 +1,51 @@ + $recipientUserIds + * @param array $metadata + */ + public function __construct( + public ChatMessage $message, + public Room $room, + public ?UserSession $sender, + public string $scope, + public array $recipientUserIds, + public array $metadata = [], + ) { + } + + public function text(): string + { + return is_string($this->message->body) ? $this->message->body : ''; + } + + public function senderDisplayName(): string + { + return $this->sender instanceof UserSession ? $this->sender->displayName : 'Unknown user'; + } + + public function isGlobal(): bool + { + return $this->scope === 'global'; + } + + public function isDirect(): bool + { + return $this->scope === 'direct'; + } + + public function isRoom(): bool + { + return $this->scope === 'room'; + } +} diff --git a/src/Chat/Bot/BotManager.php b/src/Chat/Bot/BotManager.php new file mode 100644 index 0000000..c393aba --- /dev/null +++ b/src/Chat/Bot/BotManager.php @@ -0,0 +1,64 @@ + + */ + private array $bots = []; + + public function register(BotInterface $bot): self + { + $this->bots[] = $bot; + + return $this; + } + + /** + * @return list + */ + public function all(): array + { + return $this->bots; + } + + public function hasBots(): bool + { + return $this->bots !== []; + } + + /** + * @return list + */ + public function handle(BotContext $context): array + { + $responses = []; + + foreach ($this->bots as $bot) { + $response = $bot->handle($context); + + if (!$response instanceof BotResponse) { + continue; + } + + $text = trim($response->text); + + if ($text === '') { + continue; + } + + $responses[] = [ + 'bot' => $bot, + 'response' => BotResponse::text($text, $response->metadata), + ]; + } + + return $responses; + } +} diff --git a/src/Chat/Bot/BotResponse.php b/src/Chat/Bot/BotResponse.php new file mode 100644 index 0000000..9ec95d5 --- /dev/null +++ b/src/Chat/Bot/BotResponse.php @@ -0,0 +1,25 @@ + $metadata + */ + public function __construct( + public string $text, + public array $metadata = [], + ) { + } + + /** + * @param array $metadata + */ + public static function text(string $text, array $metadata = []): self + { + return new self($text, $metadata); + } +} diff --git a/src/Chat/ChatKernel.php b/src/Chat/ChatKernel.php index d482496..d420db6 100644 --- a/src/Chat/ChatKernel.php +++ b/src/Chat/ChatKernel.php @@ -5,6 +5,8 @@ namespace Micilini\PhpSockets\Chat; use DateTimeImmutable; +use Micilini\PhpSockets\Chat\Bot\BotContext; +use Micilini\PhpSockets\Chat\Bot\BotManager; use Micilini\PhpSockets\Config\ChatConfig; use Micilini\PhpSockets\Connection\Connection; use Micilini\PhpSockets\Contracts\AttachmentStoreInterface; @@ -35,6 +37,7 @@ final class ChatKernel private readonly PrivateGroupRouter $privateGroups; private readonly AttachmentValidator $attachmentValidator; private readonly AttachmentStoreInterface $attachments; + private readonly BotManager $bots; /** * @var array): void>> @@ -47,6 +50,7 @@ public function __construct( ?MessageStoreInterface $messageStore = null, ?RoomStoreInterface $roomStore = null, ?AttachmentStoreInterface $attachmentStore = null, + ?BotManager $botManager = null, ) { $this->sessions = $sessionStore ?? new InMemorySessionStore(); $this->messages = $messageStore ?? new InMemoryMessageStore(); @@ -54,6 +58,7 @@ public function __construct( $this->validator = new PayloadValidator(); $this->attachmentValidator = new AttachmentValidator($this->config); $this->attachments = $attachmentStore ?? new FileAttachmentStore(sys_get_temp_dir() . '/phpsockets-attachments'); + $this->bots = $botManager ?? new BotManager(); $this->presence = new PresenceManager( new UsernameNormalizer($this->config->maxDisplayNameLength), $this->sessions, @@ -98,6 +103,11 @@ public function roomStore(): RoomStoreInterface return $this->rooms; } + public function bots(): BotManager + { + return $this->bots; + } + public function handleMessage( ConnectionRegistryInterface $connections, Connection $connection, @@ -210,6 +220,15 @@ private function handleGlobalMessage( 'roomId' => $room->id, 'message' => $message->toArray(), ])); + + $this->dispatchBotResponses( + connections: $connections, + sourceMessage: $message, + room: $room, + connection: $connection, + scope: 'global', + recipientUserIds: null, + ); } private function handleDirectMessage( @@ -247,6 +266,15 @@ private function handleDirectMessage( 'roomId' => $message->roomId, 'message' => $message->toArray(), ])); + + $this->dispatchBotResponses( + connections: $connections, + sourceMessage: $message, + room: $this->roomManager->createDirectRoom($fromUserId, $toUserId), + connection: $connection, + scope: 'direct', + recipientUserIds: [$fromUserId, $toUserId], + ); } private function handleRoomCreate( @@ -318,6 +346,15 @@ private function handleRoomMessage( 'roomId' => $room->id, 'message' => $message->toArray(), ])); + + $this->dispatchBotResponses( + connections: $connections, + sourceMessage: $message, + room: $room, + connection: $connection, + scope: 'room', + recipientUserIds: $room->memberUserIds, + ); } private function handleAttachmentPrepare(Connection $connection, MessageEnvelope $envelope): void @@ -661,6 +698,74 @@ private function previewDataUrl(string $mimeType, string $content): ?string return 'data:' . $mimeType . ';base64,' . base64_encode($content); } + /** + * @param list|null $recipientUserIds + */ + private function dispatchBotResponses( + ConnectionRegistryInterface $connections, + ChatMessage $sourceMessage, + Room $room, + ?Connection $connection, + string $scope, + ?array $recipientUserIds, + ): void { + if (!$this->bots->hasBots()) { + return; + } + + if ($sourceMessage->kind !== 'text') { + return; + } + + if (!is_string($sourceMessage->body) || trim($sourceMessage->body) === '') { + return; + } + + $sender = $this->sessions->findByUserId($sourceMessage->fromUserId); + $context = new BotContext( + message: $sourceMessage, + room: $room, + sender: $sender, + scope: $scope, + recipientUserIds: $recipientUserIds ?? [], + ); + + foreach ($this->bots->handle($context) as $botResult) { + $bot = $botResult['bot']; + $response = $botResult['response']; + $botMessage = ChatMessage::bot( + roomId: $room->id, + botName: $bot->name(), + text: $response->text, + metadata: $response->metadata, + ); + + $this->messages->save($botMessage); + + $this->emit('bot.responded', [ + 'bot' => $bot, + 'message' => $botMessage, + 'room' => $room, + 'sourceMessage' => $sourceMessage, + 'connection' => $connection, + 'scope' => $scope, + ]); + + $envelope = MessageEnvelope::server('message.received', [ + 'roomId' => $room->id, + 'message' => $botMessage->toArray(), + ]); + + if ($recipientUserIds === null) { + $this->broadcastAuthenticated($connections, $envelope); + + continue; + } + + $this->deliverToUsers($connections, $recipientUserIds, $envelope); + } + } + private function requireAuthenticated(Connection $connection): string { $userId = $connection->userId(); diff --git a/src/Chat/ChatMessage.php b/src/Chat/ChatMessage.php index 12f79a0..1b92df9 100644 --- a/src/Chat/ChatMessage.php +++ b/src/Chat/ChatMessage.php @@ -56,6 +56,34 @@ public static function file(string $roomId, string $fromUserId, array $body, arr ); } + /** + * @param array $metadata + */ + public static function bot(string $roomId, string $botName, string $text, array $metadata = []): self + { + $normalizedName = preg_replace('/[^a-zA-Z0-9_ -]/', '_', $botName); + + if (!is_string($normalizedName) || trim($normalizedName) === '') { + $normalizedName = 'bot'; + } + + $botId = 'bot:' . strtolower(str_replace(' ', '_', trim($normalizedName))); + + return new self( + id: 'msg_' . bin2hex(random_bytes(16)), + roomId: $roomId, + fromUserId: $botId, + kind: 'bot', + body: $text, + createdAt: new DateTimeImmutable(), + metadata: [ + 'bot' => true, + 'botName' => $botName, + ...$metadata, + ], + ); + } + /** * @return array */ diff --git a/src/Chat/ChatServer.php b/src/Chat/ChatServer.php index af1b332..645b716 100644 --- a/src/Chat/ChatServer.php +++ b/src/Chat/ChatServer.php @@ -4,6 +4,7 @@ namespace Micilini\PhpSockets\Chat; +use Micilini\PhpSockets\Chat\Bot\BotManager; use Micilini\PhpSockets\Config\ChatConfig; use Micilini\PhpSockets\Config\ServerConfig; use Micilini\PhpSockets\Server\WebSocketServer; @@ -17,6 +18,7 @@ 'user.joined' => true, 'user.left' => true, 'message.received' => true, + 'bot.responded' => true, 'room.created' => true, ]; @@ -67,4 +69,9 @@ public function kernel(): ChatKernel { return $this->kernel; } + + public function bots(): BotManager + { + return $this->kernel->bots(); + } } diff --git a/src/Contracts/BotInterface.php b/src/Contracts/BotInterface.php new file mode 100644 index 0000000..c5c5e38 --- /dev/null +++ b/src/Contracts/BotInterface.php @@ -0,0 +1,15 @@ + + */ + private array $sockets = []; + + protected function tearDown(): void + { + foreach ($this->sockets as $socket) { + socket_close($socket); + } + + $this->sockets = []; + } + + public function testGlobalTextCommandGeneratesBotResponse(): void + { + $server = $this->serverWithEchoBot(); + [$connection, $socket] = $this->authenticatedConnection($server, 'conn_william', 'William'); + + $this->drainAvailableEnvelopes($socket); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'message.global', + 'payload' => [ + 'text' => '/echo Hello bot', + ], + ]); + + $botEnvelope = $this->receiveMessageEnvelopeWithKind($socket, 'bot'); + $message = $botEnvelope['payload']['message'] ?? null; + + self::assertIsArray($message); + self::assertSame('Hello bot', $message['body'] ?? null); + self::assertSame('bot', $message['kind'] ?? null); + self::assertSame(true, $message['metadata']['bot'] ?? null); + self::assertSame('Test Echo Bot', $message['metadata']['botName'] ?? null); + } + + public function testDirectBotResponseIsDeliveredOnlyToSenderAndRecipient(): void + { + $server = $this->serverWithEchoBot(); + [$williamConnection, $williamSocket] = $this->authenticatedConnection($server, 'conn_william', 'William'); + [$anaConnection, $anaSocket] = $this->authenticatedConnection($server, 'conn_ana', 'Ana'); + [, $brunoSocket] = $this->authenticatedConnection($server, 'conn_bruno', 'Bruno'); + + $this->drainAvailableEnvelopes($williamSocket); + $this->drainAvailableEnvelopes($anaSocket); + $this->drainAvailableEnvelopes($brunoSocket); + + $this->dispatchClientMessage($server, $williamConnection, [ + 'type' => 'message.direct', + 'payload' => [ + 'toUserId' => $anaConnection->userId(), + 'text' => '/echo private', + ], + ]); + + self::assertSame('private', $this->botMessageBody($williamSocket)); + self::assertSame('private', $this->botMessageBody($anaSocket)); + self::assertFalse($this->hasBotMessage($this->drainAvailableEnvelopes($brunoSocket))); + } + + public function testPrivateGroupBotResponseIsDeliveredOnlyToMembers(): void + { + $server = $this->serverWithEchoBot(); + [$williamConnection, $williamSocket] = $this->authenticatedConnection($server, 'conn_william', 'William'); + [$anaConnection, $anaSocket] = $this->authenticatedConnection($server, 'conn_ana', 'Ana'); + [, $carlaSocket] = $this->authenticatedConnection($server, 'conn_carla', 'Carla'); + + $this->drainAvailableEnvelopes($williamSocket); + $this->drainAvailableEnvelopes($anaSocket); + $this->drainAvailableEnvelopes($carlaSocket); + + $this->dispatchClientMessage($server, $williamConnection, [ + 'type' => 'room.create', + 'payload' => [ + 'type' => Room::TYPE_PRIVATE_GROUP, + 'participantUserIds' => [$anaConnection->userId()], + ], + ]); + + $roomEnvelope = $this->receiveServerEnvelope($williamSocket, 'room.created'); + $roomId = (string) ($roomEnvelope['payload']['room']['id'] ?? ''); + + $this->drainAvailableEnvelopes($anaSocket); + $this->drainAvailableEnvelopes($carlaSocket); + + $this->dispatchClientMessage($server, $williamConnection, [ + 'type' => 'room.message', + 'payload' => [ + 'roomId' => $roomId, + 'text' => '/echo room', + ], + ]); + + self::assertSame('room', $this->botMessageBody($williamSocket)); + self::assertSame('room', $this->botMessageBody($anaSocket)); + self::assertFalse($this->hasBotMessage($this->drainAvailableEnvelopes($carlaSocket))); + } + + public function testFileMessageDoesNotTriggerBotResponse(): void + { + $server = $this->serverWithEchoBot(); + [$connection, $socket] = $this->authenticatedConnection($server, 'conn_william', 'William'); + + $this->drainAvailableEnvelopes($socket); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'message.file', + 'payload' => [ + 'scope' => 'global', + 'attachment' => [ + 'fileName' => 'hello.txt', + 'mimeType' => 'text/plain', + 'sizeBytes' => 5, + 'contentBase64' => base64_encode('hello'), + ], + ], + ]); + + $envelopes = $this->receiveAvailableEnvelopes($socket, 200000); + + self::assertTrue($this->hasEnvelopeType($envelopes, 'message.received')); + self::assertFalse($this->hasBotMessage($envelopes)); + } + + private function serverWithEchoBot(): ChatServer + { + $server = ChatServer::create(ServerConfig::new(), ChatConfig::new()); + $server->bots()->register($this->echoBot()); + + return $server; + } + + private function echoBot(): BotInterface + { + return new class () implements BotInterface { + public function name(): string + { + return 'Test Echo Bot'; + } + + public function handle(BotContext $context): ?BotResponse + { + $text = trim($context->text()); + + if (!str_starts_with($text, '/echo ')) { + return null; + } + + return BotResponse::text(trim(substr($text, 6))); + } + }; + } + + /** + * @return array{0: Connection, 1: Socket} + */ + private function authenticatedConnection(ChatServer $server, string $id, string $displayName): array + { + [$connection, $socket] = $this->registeredConnection($server, $id); + + $this->dispatchClientMessage($server, $connection, [ + 'type' => 'auth.join', + 'payload' => [ + 'displayName' => $displayName, + ], + ]); + + return [$connection, $socket]; + } + + /** + * @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)), + ); + } + + /** + * @return array{0: Connection, 1: Socket} + */ + private function registeredConnection(ChatServer $server, string $id): array + { + [$clientSocket, $peerSocket] = $this->connectedSocketPair(); + socket_set_nonblock($clientSocket); + + $connection = new Connection($id, $peerSocket, new FrameCodec()); + + $server->webSocketServer()->connections()->add($connection); + + return [$connection, $clientSocket]; + } + + private function botMessageBody(Socket $socket): string + { + $envelope = $this->receiveMessageEnvelopeWithKind($socket, 'bot'); + $message = $envelope['payload']['message'] ?? null; + + if (!is_array($message)) { + return ''; + } + + return (string) ($message['body'] ?? ''); + } + + /** + * @return array + */ + private function receiveMessageEnvelopeWithKind(Socket $socket, string $kind): array + { + for ($attempt = 0; $attempt < 10; $attempt++) { + foreach ($this->receiveAvailableEnvelopes($socket, 200000) as $envelope) { + $message = $envelope['payload']['message'] ?? null; + + if (($envelope['type'] ?? null) === 'message.received' && is_array($message) && ($message['kind'] ?? null) === $kind) { + return $envelope; + } + } + } + + throw new RuntimeException("Expected message.received envelope with kind {$kind} was not received."); + } + + /** + * @return array + */ + private function receiveServerEnvelope(Socket $socket, string $expectedType): array + { + for ($attempt = 0; $attempt < 10; $attempt++) { + foreach ($this->receiveAvailableEnvelopes($socket, 200000) as $envelope) { + if (($envelope['type'] ?? null) === $expectedType) { + return $envelope; + } + } + } + + throw new RuntimeException("Expected server envelope {$expectedType} was not received."); + } + + /** + * @return list> + */ + private function drainAvailableEnvelopes(Socket $socket): array + { + $envelopes = []; + + do { + $batch = $this->receiveAvailableEnvelopes($socket, 0); + $envelopes = [...$envelopes, ...$batch]; + } while ($batch !== []); + + return $envelopes; + } + + /** + * @return list> + */ + private function receiveAvailableEnvelopes(Socket $socket, int $timeoutMicroseconds): array + { + $readSockets = [$socket]; + $writeSockets = null; + $exceptSockets = null; + $changed = socket_select($readSockets, $writeSockets, $exceptSockets, 0, $timeoutMicroseconds); + + if ($changed === false || $changed === 0) { + return []; + } + + $data = ''; + $bytes = socket_recv($socket, $data, 8192, 0); + + if ($bytes === false || $bytes === 0) { + return []; + } + + $codec = new FrameCodec(); + $envelopes = []; + + foreach ($codec->decodeAll($data, fromClient: false) as $frame) { + $envelope = json_decode($frame->payload, true, 512, JSON_THROW_ON_ERROR); + + if (is_array($envelope)) { + $envelopes[] = $envelope; + } + } + + return $envelopes; + } + + /** + * @param list> $envelopes + */ + private function hasEnvelopeType(array $envelopes, string $type): bool + { + foreach ($envelopes as $envelope) { + if (($envelope['type'] ?? null) === $type) { + return true; + } + } + + return false; + } + + /** + * @param list> $envelopes + */ + private function hasBotMessage(array $envelopes): bool + { + foreach ($envelopes as $envelope) { + $message = $envelope['payload']['message'] ?? null; + + if (($envelope['type'] ?? null) === 'message.received' && is_array($message) && ($message['kind'] ?? null) === 'bot') { + return true; + } + } + + return false; + } + + /** + * @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/BotManagerTest.php b/tests/Unit/Chat/BotManagerTest.php new file mode 100644 index 0000000..189c89a --- /dev/null +++ b/tests/Unit/Chat/BotManagerTest.php @@ -0,0 +1,100 @@ +echoBot(); + $manager = new BotManager(); + + self::assertFalse($manager->hasBots()); + self::assertSame($manager, $manager->register($bot)); + self::assertTrue($manager->hasBots()); + self::assertSame([$bot], $manager->all()); + } + + public function testHandleReturnsMatchingBotResponse(): void + { + $manager = new BotManager(); + $manager->register($this->echoBot()); + + $responses = $manager->handle($this->context('/echo Hello')); + + self::assertCount(1, $responses); + self::assertSame('Test Echo Bot', $responses[0]['bot']->name()); + self::assertSame('Hello', $responses[0]['response']->text); + } + + public function testHandleIgnoresNullAndEmptyResponses(): void + { + $manager = new BotManager(); + $manager + ->register($this->echoBot()) + ->register($this->emptyBot()); + + self::assertSame([], $manager->handle($this->context('/noop'))); + } + + private function context(string $text): BotContext + { + return new BotContext( + message: ChatMessage::text('global', 'usr_william', $text), + room: Room::global(), + sender: null, + scope: 'global', + recipientUserIds: [], + ); + } + + private function echoBot(): BotInterface + { + return new class () implements BotInterface { + public function name(): string + { + return 'Test Echo Bot'; + } + + public function handle(BotContext $context): ?BotResponse + { + $text = trim($context->text()); + + if (!str_starts_with($text, '/echo ')) { + return null; + } + + return BotResponse::text(substr($text, 6)); + } + }; + } + + private function emptyBot(): BotInterface + { + return new class () implements BotInterface { + public function name(): string + { + return 'Empty Bot'; + } + + public function handle(BotContext $context): ?BotResponse + { + if ($context->text() === '/never') { + return null; + } + + return BotResponse::text(' '); + } + }; + } +}