Files
portal/app/app/Http/Controllers/Api/ChatController.php
T

116 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\Bot\ProcessChatMessageJob;
use App\Models\BotDialog;
use App\Services\Bot\ChatRateLimiter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Свой чат (спека 2026-07-13-own-chat-widget-design §5). Заменяет вебхук Jivo.
* Реплику клиента пишем ЗДЕСЬ и возвращаем её номер: окошко опрашивает ответы
* «после» него и не получает обратно собственное сообщение.
* Публичный адрес (лендинг открыт всему интернету) — защита в ChatRateLimiter (Task 4).
*/
class ChatController extends Controller
{
public function send(Request $request): JsonResponse
{
$data = $request->validate([
'chat_id' => ['required', 'string', 'regex:/^[a-f0-9]{32}$/'],
'text' => ['required', 'string', 'max:1000'],
]);
$text = trim($data['text']);
if ($text === '') {
return response()->json(['message' => 'Пустое сообщение.'], 422);
}
$userId = Auth::id();
if ($this->deniesAccess($data['chat_id'])) {
return response()->json(['message' => 'Разговор не найден.'], 404);
}
$refusal = app(ChatRateLimiter::class)
->check($data['chat_id'], (string) $request->ip(), $userId !== null);
if ($refusal !== null) {
return response()->json(['message' => $refusal], 429);
}
$row = BotDialog::create([
'chat_id' => $data['chat_id'],
'direction' => 'in',
'message' => $text,
'source' => $userId !== null ? 'portal' : 'landing',
'user_id' => $userId,
'ip' => $request->ip(),
'created_at' => now(),
]);
ProcessChatMessageJob::dispatch($data['chat_id'], (int) $row->id, $text);
return response()->json(['message_id' => (int) $row->id]);
}
/**
* Опрос окошка: «что нового в разговоре после реплики N?».
* after=0 → вся переписка (клиент перезагрузил страницу и вернулся).
*/
public function messages(Request $request, string $chat): JsonResponse
{
if ($this->deniesAccess($chat)) {
return response()->json(['message' => 'Разговор не найден.'], 404);
}
$after = max(0, (int) $request->query('after', '0'));
$rows = BotDialog::query()
->where('chat_id', $chat)
->where('id', '>', $after)
->orderBy('id')
->limit(100)
->get(['id', 'direction', 'message', 'created_at']);
return response()->json([
'messages' => $rows->map(fn (BotDialog $d) => [
'id' => (int) $d->id,
'direction' => $d->direction,
'text' => $d->message,
'at' => $d->created_at?->toIso8601String(),
])->all(),
]);
}
/**
* Владелец разговора: первый вошедший клиент, писавший в него. Личные цифры (баланс,
* проекты) уходят в переписку — значит, чужому её видеть нельзя, а ключ разговора
* живёт в браузере и может попасть в чужие руки.
*/
private function ownerOf(string $chatId): ?int
{
$owner = BotDialog::query()
->where('chat_id', $chatId)
->whereNotNull('user_id')
->orderBy('id')
->value('user_id');
return $owner === null ? null : (int) $owner;
}
/** Чужому не подтверждаем даже существование разговора — 404, не 403. */
private function deniesAccess(string $chatId): bool
{
$owner = $this->ownerOf($chatId);
return $owner !== null && $owner !== Auth::id();
}
}