116 lines
4.2 KiB
PHP
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();
|
|
}
|
|
}
|