Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion examples/easy-chat/public/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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';
Expand Down
19 changes: 19 additions & 0 deletions examples/easy-chat/public/assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion examples/medium-chat/public/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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';
Expand Down
19 changes: 19 additions & 0 deletions examples/medium-chat/public/assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions examples/private-chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>
```

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.
Expand Down
34 changes: 34 additions & 0 deletions examples/private-chat/bots/EchoBot.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Examples\PrivateChat\Bots;

use Micilini\PhpSockets\Chat\Bot\BotContext;
use Micilini\PhpSockets\Chat\Bot\BotResponse;
use Micilini\PhpSockets\Contracts\BotInterface;

final class EchoBot implements BotInterface
{
public function name(): string
{
return 'Echo Bot';
}

public function handle(BotContext $context): ?BotResponse
{
$text = trim($context->text());

if (!str_starts_with($text, '/echo ')) {
return null;
}

$message = trim(substr($text, 6));

if ($message === '') {
return BotResponse::text('Usage: /echo <text>');
}

return BotResponse::text($message);
}
}
26 changes: 26 additions & 0 deletions examples/private-chat/bots/HelpBot.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Examples\PrivateChat\Bots;

use Micilini\PhpSockets\Chat\Bot\BotContext;
use Micilini\PhpSockets\Chat\Bot\BotResponse;
use Micilini\PhpSockets\Contracts\BotInterface;

final class HelpBot implements BotInterface
{
public function name(): string
{
return 'Help Bot';
}

public function handle(BotContext $context): ?BotResponse
{
if (trim($context->text()) !== '/help') {
return null;
}

return BotResponse::text('Available commands: /help, /echo <text>');
}
}
24 changes: 23 additions & 1 deletion examples/private-chat/public/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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';
Expand Down
19 changes: 19 additions & 0 deletions examples/private-chat/public/assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions examples/private-chat/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
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;
use Micilini\PhpSockets\Chat\UserSession;
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);
Expand All @@ -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";
});
Expand Down Expand Up @@ -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();
Loading
Loading