From cfd26e38ef0ff5a5bf6bf462d7f38574acf86b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 13 Jul 2026 07:04:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=D1=81=D0=B2=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=91=D0=BC=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B2=D0=B5=D0=B1=D1=85=D1=83=D0=BA=D0=B0=20Jivo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменяет JivoBotController+JivoBotClient на ChatController (POST /api/chat/message) и ProcessJivoMessageJob на ProcessChatMessageJob. Реплику клиента теперь пишет контроллер и возвращает её номер (message_id); джоба получает этот номер и берёт историю строго до него, не отправляя ответ во внешний Jivo — он ложится в bot_dialogs, откуда его заберёт своё окошко (Задача 3). Мозг бота не менялся. Спека: docs/superpowers/specs/2026-07-13-own-chat-widget-design.md §5 --- .../Http/Controllers/Api/ChatController.php | 50 +++++++++++++ .../Controllers/Api/JivoBotController.php | 38 ---------- ...ssageJob.php => ProcessChatMessageJob.php} | 55 +++++--------- app/app/Services/Bot/BotAnswerService.php | 2 +- app/app/Services/Bot/JivoBotClient.php | 56 -------------- app/bootstrap/app.php | 3 + app/routes/web.php | 8 +- .../Feature/Bot/BotContactCaptureTest.php | 36 ++++++--- app/tests/Feature/Bot/BotSpeedBudgetTest.php | 14 +++- app/tests/Feature/Bot/ChatEndpointTest.php | 46 ++++++++++++ app/tests/Feature/Bot/DialogRunFixesTest.php | 29 ++++++-- app/tests/Feature/Bot/JivoBotWebhookTest.php | 73 ------------------- ...Test.php => ProcessChatMessageJobTest.php} | 53 ++++++++------ app/tests/Unit/Bot/JivoBotClientTest.php | 45 ------------ 14 files changed, 205 insertions(+), 303 deletions(-) create mode 100644 app/app/Http/Controllers/Api/ChatController.php delete mode 100644 app/app/Http/Controllers/Api/JivoBotController.php rename app/app/Jobs/Bot/{ProcessJivoMessageJob.php => ProcessChatMessageJob.php} (54%) delete mode 100644 app/app/Services/Bot/JivoBotClient.php create mode 100644 app/tests/Feature/Bot/ChatEndpointTest.php delete mode 100644 app/tests/Feature/Bot/JivoBotWebhookTest.php rename app/tests/Feature/Bot/{ProcessJivoMessageJobTest.php => ProcessChatMessageJobTest.php} (61%) delete mode 100644 app/tests/Unit/Bot/JivoBotClientTest.php diff --git a/app/app/Http/Controllers/Api/ChatController.php b/app/app/Http/Controllers/Api/ChatController.php new file mode 100644 index 00000000..3a125cc2 --- /dev/null +++ b/app/app/Http/Controllers/Api/ChatController.php @@ -0,0 +1,50 @@ +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(); + + $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]); + } +} diff --git a/app/app/Http/Controllers/Api/JivoBotController.php b/app/app/Http/Controllers/Api/JivoBotController.php deleted file mode 100644 index 2695602a..00000000 --- a/app/app/Http/Controllers/Api/JivoBotController.php +++ /dev/null @@ -1,38 +0,0 @@ -json(['message' => 'Not found.'], 404); - } - - $event = (string) $request->input('event', ''); - $text = trim((string) $request->input('message.text', '')); - $chatId = (string) $request->input('chat_id', ''); - - if ($event === 'CLIENT_MESSAGE' && $text !== '' && $chatId !== '') { - ProcessJivoMessageJob::dispatch($chatId, (string) $request->input('client_id', ''), $text); - } - - return response()->json(['ok' => true]); - } -} diff --git a/app/app/Jobs/Bot/ProcessJivoMessageJob.php b/app/app/Jobs/Bot/ProcessChatMessageJob.php similarity index 54% rename from app/app/Jobs/Bot/ProcessJivoMessageJob.php rename to app/app/Jobs/Bot/ProcessChatMessageJob.php index 5a7c960a..70a3d2fe 100644 --- a/app/app/Jobs/Bot/ProcessJivoMessageJob.php +++ b/app/app/Jobs/Bot/ProcessChatMessageJob.php @@ -9,23 +9,21 @@ use App\Models\BotDialog; use App\Services\Bot\BotAnswer; use App\Services\Bot\BotAnswerService; use App\Services\Bot\ContactCapture; -use App\Services\Bot\JivoBotClient; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; /** - * Оркестратор ответа бота (спека §2). Очередь `bot` — отдельный worker на проде, - * чтобы поток лидов не задерживал ответы чата (скорость — требование №1). - * timeout 12с < 15с Jivo-страховки: не успели — Jivo сам позовёт оператора. - * $tries=1: ретраить разговор бессмысленно. + * Оркестратор ответа бота. Очередь `bot` — отдельный worker, чтобы поток лидов + * не задерживал ответы чата (скорость — требование №1). $tries=1: ретраить разговор + * бессмысленно. Реплику клиента пишет ChatController; сюда приходит её номер ($inId), + * чтобы история разговора не включала текущий вопрос дважды. * - * Эскалация (правило владельца 12.07.2026): бот НЕ переводит чат на оператора - * (INVITE_AGENT) — оператора в чате может не быть. Вместо этого он просит телефон; - * как только клиент его оставил, контакт уходит письмом в поддержку. + * Эскалация (правило владельца 12.07.2026): живого оператора в чате нет — бот просит + * телефон, и контакт уходит письмом в поддержку. */ -class ProcessJivoMessageJob implements ShouldQueue +class ProcessChatMessageJob implements ShouldQueue { use Queueable; @@ -35,17 +33,16 @@ class ProcessJivoMessageJob implements ShouldQueue public function __construct( public readonly string $chatId, - public readonly string $clientId, + public readonly int $inId, public readonly string $text, ) { $this->onQueue('bot'); } /** - * Сколько прошлых реплик чата отдаём боту как память разговора (5 пар вопрос-ответ). - * Было 3 пары — и в живом диалоге бот «забывал» цифру, названную им же в начале: - * клиент спрашивал «минималка какая была, я забыл», а тот ответ уже выпал из окна - * (прогон диалогами 12.07.2026). Пять пар покрывают типичный разговор целиком. + * Сколько прошлых реплик отдаём боту как память разговора (5 пар вопрос-ответ). + * Было 3 пары — в живом диалоге бот «забывал» цифру, названную им же в начале + * (прогон диалогами 12.07.2026). */ private const HISTORY_LIMIT = 10; @@ -53,10 +50,9 @@ class ProcessJivoMessageJob implements ShouldQueue { $startedAt = hrtime(true); - // Память разговора — ДО записи текущего сообщения (иначе оно попадёт в историю). - // Служебные хвосты «👉 Показать на портале: …» из прошлых ответов LLM не нужны. $history = BotDialog::query() ->where('chat_id', $this->chatId) + ->where('id', '<', $this->inId) ->orderByDesc('id') ->limit(self::HISTORY_LIMIT) ->get(['direction', 'message']) @@ -68,22 +64,12 @@ class ProcessJivoMessageJob implements ShouldQueue ->values() ->all(); - BotDialog::create([ - 'chat_id' => $this->chatId, - 'direction' => 'in', - 'message' => $this->text, - 'created_at' => now(), - ]); - - // Номер в реплике = человек хочет, чтобы ему перезвонили. Принимаем ВСЕГДА, просил - // бот телефон или нет. Прогон диалогами 12.07.2026: клиент сам оставил номер, бот - // ответил «не могу работать с персональными данными в чате» — и контакт пропал. + // Номер в реплике = человек хочет, чтобы ему перезвонили. Принимаем ВСЕГДА, + // просил бот телефон или нет (прогон диалогами 12.07.2026: контакт терялся). $answer = ($this->awaitsContact() || app(ContactCapture::class)->extractPhone($this->text) !== null) ? $this->captureContact($history) : app(BotAnswerService::class)->answer($this->text, $history); - app(JivoBotClient::class)->sendMessage($this->chatId, $this->clientId, $answer->text); - BotDialog::create([ 'chat_id' => $this->chatId, 'direction' => 'out', @@ -101,16 +87,13 @@ class ProcessJivoMessageJob implements ShouldQueue $lastOut = BotDialog::query() ->where('chat_id', $this->chatId) ->where('direction', 'out') + ->where('id', '<', $this->inId) ->latest('id') ->value('message'); return is_string($lastOut) && str_contains($lastOut, 'номер телефона'); } - /** - * Клиент ответил на просьбу о телефоне. Номер есть — шлём контакт письмом - * в поддержку; номера нет (передумал, задал новый вопрос) — отвечаем как обычно. - */ /** * @param list $history */ @@ -119,7 +102,6 @@ class ProcessJivoMessageJob implements ShouldQueue $phone = app(ContactCapture::class)->extractPhone($this->text); if ($phone === null) { // Клиент передумал и задал новый вопрос — отвечаем С ПАМЯТЬЮ разговора. - // Раньше история сюда не передавалась, и бот терял нить (прогон 12.07.2026). return app(BotAnswerService::class)->answer($this->text, $history); } @@ -131,7 +113,6 @@ class ProcessJivoMessageJob implements ShouldQueue ->reverse() ->values(); - // Вопрос, на котором бот спасовал, — последняя реплика клиента ПЕРЕД просьбой о номере. $question = $rows ->filter(fn (BotDialog $d) => $d->direction === 'in') ->slice(0, -1) @@ -143,13 +124,11 @@ class ProcessJivoMessageJob implements ShouldQueue $to = (string) config('services.support.email'); if ($to !== '') { - // Письмо — В ОЧЕРЕДЬ, не здесь. Прогон диалогами 12.07.2026: почта не ответила → - // упала вся задача (tries=1), и клиент, оставивший номер, не получил НИЧЕГО. - // Ответ клиенту важнее письма; очередь сама повторит отправку. + // Письмо — В ОЧЕРЕДЬ. Прогон 12.07.2026: почта не ответила → упала вся задача + // (tries=1), и клиент, оставивший номер, не получил НИЧЕГО. try { Mail::to($to)->queue(new BotContactRequestMail($phone, $question, $this->chatId, $transcript)); } catch (\Throwable $e) { - // Номер уже в журнале bot_dialogs — не потеряется. Клиенту отвечаем как обычно. Log::error('Не удалось поставить в очередь письмо с контактом', [ 'chat_id' => $this->chatId, 'error' => $e->getMessage(), diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index d220de30..b83d458e 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -103,7 +103,7 @@ class BotAnswerService * Эскалация (правило владельца 12.07.2026): бот не переводит чат на оператора — * оператор не сидит в чате круглосуточно. Бот ПРОСИТ телефон и присылает контакт * письмом, чтобы человек перезвонил. Тексты содержат маркер «номер телефона» — - * по нему ProcessJivoMessageJob понимает, что ждёт контакт. + * по нему ProcessChatMessageJob понимает, что ждёт контакт. */ public const ESCALATE_TEXT = 'Этот вопрос лучше разберёт живой специалист. Оставьте, пожалуйста, ваш номер телефона — специалист свяжется с вами и всё подскажет.'; diff --git a/app/app/Services/Bot/JivoBotClient.php b/app/app/Services/Bot/JivoBotClient.php deleted file mode 100644 index d28bfe9e..00000000 --- a/app/app/Services/Bot/JivoBotClient.php +++ /dev/null @@ -1,56 +0,0 @@ -post([ - 'event' => 'BOT_MESSAGE', - 'id' => (string) Str::uuid(), - 'chat_id' => $chatId, - 'client_id' => $clientId, - 'message' => ['type' => 'TEXT', 'text' => $text, 'timestamp' => now()->getTimestamp()], - ]); - } - - public function inviteAgent(string $chatId, string $clientId): void - { - $this->post([ - 'event' => 'INVITE_AGENT', - 'id' => (string) Str::uuid(), - 'chat_id' => $chatId, - 'client_id' => $clientId, - ]); - } - - /** @param array $payload */ - private function post(array $payload): void - { - $url = (string) config('services.jivo_bot.outbound_url'); - if ($url === '') { - Log::info('JivoBot outbound skipped (no outbound_url)', ['event' => $payload['event']]); - - return; - } - - try { - Http::timeout(5)->post($url, $payload)->throw(); - } catch (\Throwable $e) { - Log::warning('JivoBot outbound failure', ['event' => $payload['event'], 'error' => $e->getMessage()]); - } - } -} diff --git a/app/bootstrap/app.php b/app/bootstrap/app.php index a10f2fcb..aca129b2 100644 --- a/app/bootstrap/app.php +++ b/app/bootstrap/app.php @@ -57,6 +57,9 @@ return Application::configure(basePath: dirname(__DIR__)) // Авторизация — через webhook_token в URL + (на prod) HMAC. $middleware->validateCsrfTokens(except: [ 'api/webhook/*', + // Чат открыт гостям с лендинга: сессии и CSRF-токена у них нет. + // Защита — счётчики ChatRateLimiter (спека §6), не CSRF. + 'api/chat/*', ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/app/routes/web.php b/app/routes/web.php index eadf2f27..1140cca2 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -383,11 +383,9 @@ Route::post('/api/webhook/supplier', 'App\Http\Controllers\Api\SupplierWebhookCo Route::post('/api/webhook/supplier/{secret}', 'App\Http\Controllers\Api\SupplierWebhookController@receive') ->where('secret', '[A-Za-z0-9_\-]+'); -// ИИ-бот техподдержки: события Jivo Bot API (CLIENT_MESSAGE и служебные). -// Защита — секрет в URL по образцу supplier-webhook; ack мгновенный, работа в джобе -// (спека docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md §5). -Route::post('/api/webhook/jivo/{secret}', 'App\Http\Controllers\Api\JivoBotController@receive') - ->where('secret', '[A-Za-z0-9_\-]+'); +// Свой чат: окошко на портале и лендинге (спека 2026-07-13-own-chat-widget-design §5). +// Публичный адрес — лендинг открыт всему интернету; защита от накрутки — ChatRateLimiter. +Route::post('/api/chat/message', 'App\Http\Controllers\Api\ChatController@send'); // Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt. // Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7. diff --git a/app/tests/Feature/Bot/BotContactCaptureTest.php b/app/tests/Feature/Bot/BotContactCaptureTest.php index bd0009fd..10dc1744 100644 --- a/app/tests/Feature/Bot/BotContactCaptureTest.php +++ b/app/tests/Feature/Bot/BotContactCaptureTest.php @@ -2,12 +2,11 @@ declare(strict_types=1); -use App\Jobs\Bot\ProcessJivoMessageJob; +use App\Jobs\Bot\ProcessChatMessageJob; use App\Mail\BotContactRequestMail; use App\Models\BotDialog; use App\Models\KnowledgeChunk; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; uses(RefreshDatabase::class); @@ -17,7 +16,6 @@ uses(RefreshDatabase::class); * не сидит в чате круглосуточно), а ПРОСИТ телефон и присылает контакт письмом. */ beforeEach(function () { - config()->set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/p/t'); config()->set('services.support.email', 'support@liderra.ru'); config()->set('services.yandexgpt', [ 'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest', @@ -29,27 +27,41 @@ beforeEach(function () { 'topics' => 'создать проект', 'chunk_index' => 0, 'content' => 'Проект — это заявка на поток клиентов.', ]); - Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]); }); -it('эскалация просит телефон, а не зовёт оператора в чат', function () { +/** + * Мимикрирует то, что делает ChatController: пишет реплику клиента в журнал и + * запускает джобу с её номером. + */ +function chatTurn(string $chatId, string $text): void +{ + $row = BotDialog::create([ + 'chat_id' => $chatId, + 'direction' => 'in', + 'message' => $text, + 'created_at' => now(), + ]); + + (new ProcessChatMessageJob($chatId, (int) $row->id, $text))->handle(); +} + +it('эскалация просит телефон', function () { Mail::fake(); - (new ProcessJivoMessageJob('chat-c1', 'client-1', 'какой у меня баланс?'))->handle(); + chatTurn('chat-c1', 'какой у меня баланс?'); $out = BotDialog::where('direction', 'out')->firstOrFail(); expect($out->escalated)->toBeTrue() ->and($out->message)->toContain('номер телефона'); - Http::assertNotSent(fn ($r) => ($r['event'] ?? '') === 'INVITE_AGENT'); Mail::assertNothingSent(); }); it('клиент прислал телефон после просьбы → письмо с контактом и вопросом уходит в поддержку', function () { Mail::fake(); - (new ProcessJivoMessageJob('chat-c2', 'client-2', 'какой у меня баланс?'))->handle(); - (new ProcessJivoMessageJob('chat-c2', 'client-2', 'мой номер +7 916 123-45-67'))->handle(); + chatTurn('chat-c2', 'какой у меня баланс?'); + chatTurn('chat-c2', 'мой номер +7 916 123-45-67'); // Письмо уходит ЧЕРЕЗ ОЧЕРЕДЬ: почта не должна ронять ответ клиенту (прогон диалогами // 12.07.2026 — SMTP не ответил, задача упала, клиент с номером остался без ответа). @@ -69,7 +81,7 @@ it('телефон без просьбы бота — тоже принимае // Номер в чате = заявка на звонок. Не теряем его никогда. Mail::fake(); - (new ProcessJivoMessageJob('chat-c3', 'client-3', 'звоните мне на +79161234567 если что'))->handle(); + chatTurn('chat-c3', 'звоните мне на +79161234567 если что'); Mail::assertQueued(BotContactRequestMail::class, fn ($m) => $m->phone === '79161234567'); $last = BotDialog::where('direction', 'out')->latest('id')->firstOrFail(); @@ -82,8 +94,8 @@ it('номер распознаётся в разных форматах', funct $variants = ['8 (916) 123-45-67', '+7-916-123-45-67', '79161234567']; foreach ($variants as $i => $phone) { $chat = 'chat-fmt-'.$i; - (new ProcessJivoMessageJob($chat, 'c', 'какой у меня баланс?'))->handle(); - (new ProcessJivoMessageJob($chat, 'c', $phone))->handle(); + chatTurn($chat, 'какой у меня баланс?'); + chatTurn($chat, $phone); } Mail::assertQueued(BotContactRequestMail::class, 3); diff --git a/app/tests/Feature/Bot/BotSpeedBudgetTest.php b/app/tests/Feature/Bot/BotSpeedBudgetTest.php index dee3f1a2..390f0e9d 100644 --- a/app/tests/Feature/Bot/BotSpeedBudgetTest.php +++ b/app/tests/Feature/Bot/BotSpeedBudgetTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Jobs\Bot\ProcessJivoMessageJob; +use App\Jobs\Bot\ProcessChatMessageJob; use App\Models\BotDialog; use App\Models\KnowledgeChunk; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); it('наша часть тракта (без сети) укладывается в 500 мс на вопрос', function () { - config()->set('services.jivo_bot.outbound_url', ''); // исходящие в лог config()->set('services.yandexgpt', [ 'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest', 'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion', @@ -32,8 +31,15 @@ it('наша часть тракта (без сети) укладывается $latencies = []; for ($i = 0; $i < 10; $i++) { - (new ProcessJivoMessageJob("chat-{$i}", 'c', 'что такое проект?'))->handle(); - $latencies[] = (int) BotDialog::where('chat_id', "chat-{$i}") + $chat = "chat-{$i}"; + $row = BotDialog::create([ + 'chat_id' => $chat, + 'direction' => 'in', + 'message' => 'что такое проект?', + 'created_at' => now(), + ]); + (new ProcessChatMessageJob($chat, (int) $row->id, 'что такое проект?'))->handle(); + $latencies[] = (int) BotDialog::where('chat_id', $chat) ->where('direction', 'out')->value('latency_ms'); } diff --git a/app/tests/Feature/Bot/ChatEndpointTest.php b/app/tests/Feature/Bot/ChatEndpointTest.php new file mode 100644 index 00000000..00ab4dc1 --- /dev/null +++ b/app/tests/Feature/Bot/ChatEndpointTest.php @@ -0,0 +1,46 @@ +postJson('/api/chat/message', [ + 'chat_id' => $chat, + 'text' => 'сколько стоит заявка?', + ]); + + $response->assertOk(); + $id = $response->json('message_id'); + expect($id)->toBeInt(); + + $row = BotDialog::query()->find($id); + expect($row->chat_id)->toBe($chat) + ->and($row->direction)->toBe('in') + ->and($row->message)->toBe('сколько стоит заявка?') + ->and($row->source)->toBe('landing'); // гость → лендинг + + Queue::assertPushed(ProcessChatMessageJob::class); +}); + +it('не принимает слишком длинное сообщение', function () { + $this->postJson('/api/chat/message', [ + 'chat_id' => str_repeat('b', 32), + 'text' => str_repeat('я', 1001), + ])->assertStatus(422); +}); + +it('не принимает кривой номер разговора', function () { + $this->postJson('/api/chat/message', [ + 'chat_id' => 'не-номер', + 'text' => 'привет', + ])->assertStatus(422); +}); diff --git a/app/tests/Feature/Bot/DialogRunFixesTest.php b/app/tests/Feature/Bot/DialogRunFixesTest.php index 402f6904..8e9c6dbb 100644 --- a/app/tests/Feature/Bot/DialogRunFixesTest.php +++ b/app/tests/Feature/Bot/DialogRunFixesTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Jobs\Bot\ProcessJivoMessageJob; +use App\Jobs\Bot\ProcessChatMessageJob; use App\Mail\BotContactRequestMail; use App\Models\BotDialog; use App\Models\KnowledgeChunk; @@ -20,7 +20,6 @@ uses(RefreshDatabase::class); * что уже сказал, и правильно принять оставленный телефон. */ beforeEach(function () { - config()->set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/p/t'); config()->set('services.support.email', 'support@liderra.ru'); config()->set('services.yandexgpt', [ 'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest', @@ -32,20 +31,34 @@ beforeEach(function () { 'topics' => 'пополнить, баланс, минималка', 'chunk_index' => 0, 'content' => 'Пополнить баланс можно картой или по счёту.', ]); - Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]); }); /** Ответ модели, который получит бот. */ function llmSays(string $text): void { Http::fake([ - 'bot.jivosite.com/*' => Http::response(['ok' => true]), 'llm.api.cloud.yandex.net/*' => Http::response([ 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => $text]]]], ]), ]); } +/** + * Мимикрирует то, что делает ChatController: пишет реплику клиента в журнал и + * запускает джобу с её номером. + */ +function sendChat(string $chatId, string $text): void +{ + $row = BotDialog::create([ + 'chat_id' => $chatId, + 'direction' => 'in', + 'message' => $text, + 'created_at' => now(), + ]); + + (new ProcessChatMessageJob($chatId, (int) $row->id, $text))->handle(); +} + /** * 🔴 Находка 1 (D28): клиент оставил телефон, почта не ответила — задача упала (tries=1), * и клиент не получил НИЧЕГО. Ответ клиенту важнее письма. @@ -53,8 +66,8 @@ function llmSays(string $text): void it('почта упала — клиент всё равно получил подтверждение, что номер принят', function () { Mail::shouldReceive('to')->once()->andThrow(new RuntimeException('SMTP недоступен')); - (new ProcessJivoMessageJob('chat-mailfail', 'c1', 'верните мне деньги'))->handle(); - (new ProcessJivoMessageJob('chat-mailfail', 'c1', '+7 999 123-45-67'))->handle(); + sendChat('chat-mailfail', 'верните мне деньги'); + sendChat('chat-mailfail', '+7 999 123-45-67'); $last = BotDialog::where('direction', 'out')->latest('id')->firstOrFail(); expect($last->message)->toBe(BotAnswerService::CONTACT_TAKEN_TEXT); @@ -69,8 +82,8 @@ it('клиент оставил номер сам, без просьбы бот Mail::fake(); llmSays('Пополнить баланс можно картой.'); - (new ProcessJivoMessageJob('chat-selfphone', 'c2', 'как пополнить баланс'))->handle(); - (new ProcessJivoMessageJob('chat-selfphone', 'c2', 'вот мой номер +7 900 000-11-22'))->handle(); + sendChat('chat-selfphone', 'как пополнить баланс'); + sendChat('chat-selfphone', 'вот мой номер +7 900 000-11-22'); Mail::assertQueued(BotContactRequestMail::class, fn ($m) => $m->phone === '79000001122'); diff --git a/app/tests/Feature/Bot/JivoBotWebhookTest.php b/app/tests/Feature/Bot/JivoBotWebhookTest.php deleted file mode 100644 index 2838e797..00000000 --- a/app/tests/Feature/Bot/JivoBotWebhookTest.php +++ /dev/null @@ -1,73 +0,0 @@ -set('services.jivo_bot.webhook_secret', JIVO_SECRET); -}); - -function jivoPayload(string $text = 'что такое проект?'): array -{ - return [ - 'event' => 'CLIENT_MESSAGE', - 'id' => 'evt-1', - 'chat_id' => 'chat-1', - 'client_id' => 'client-1', - 'message' => ['type' => 'TEXT', 'text' => $text, 'timestamp' => 1780000000], - ]; -} - -it('валидный секрет + CLIENT_MESSAGE → 200 и джоба в очереди bot', function () { - Queue::fake(); - - $this->postJson('/api/webhook/jivo/'.JIVO_SECRET, jivoPayload())->assertOk(); - - Queue::assertPushedOn('bot', ProcessJivoMessageJob::class, function (ProcessJivoMessageJob $job) { - return $job->chatId === 'chat-1' && $job->text === 'что такое проект?'; - }); -}); - -it('неверный секрет → 404 без джобы', function () { - Queue::fake(); - - $this->postJson('/api/webhook/jivo/wrong-secret', jivoPayload())->assertNotFound(); - - Queue::assertNothingPushed(); -}); - -it('секрет не настроен (пустой конфиг) → 404 даже с пустым секретом в URL', function () { - config()->set('services.jivo_bot.webhook_secret', ''); - Queue::fake(); - - $this->postJson('/api/webhook/jivo/anything', jivoPayload())->assertNotFound(); - - Queue::assertNothingPushed(); -}); - -it('не-CLIENT_MESSAGE (служебное событие) → 200 без джобы', function () { - Queue::fake(); - - $this->postJson('/api/webhook/jivo/'.JIVO_SECRET, ['event' => 'AGENT_JOINED', 'chat_id' => 'c']) - ->assertOk(); - - Queue::assertNothingPushed(); -}); - -it('CLIENT_MESSAGE без текста → 200 без джобы', function () { - Queue::fake(); - - $payload = jivoPayload(); - $payload['message']['text'] = ''; - - $this->postJson('/api/webhook/jivo/'.JIVO_SECRET, $payload)->assertOk(); - - Queue::assertNothingPushed(); -}); diff --git a/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php b/app/tests/Feature/Bot/ProcessChatMessageJobTest.php similarity index 61% rename from app/tests/Feature/Bot/ProcessJivoMessageJobTest.php rename to app/tests/Feature/Bot/ProcessChatMessageJobTest.php index 9185dd45..cfd53ab4 100644 --- a/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php +++ b/app/tests/Feature/Bot/ProcessChatMessageJobTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Jobs\Bot\ProcessJivoMessageJob; +use App\Jobs\Bot\ProcessChatMessageJob; use App\Models\BotDialog; use App\Models\KnowledgeChunk; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); beforeEach(function () { - config()->set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/p/t'); config()->set('services.yandexgpt', [ 'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest', 'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion', @@ -24,17 +23,30 @@ beforeEach(function () { ]); }); -it('happy path: ответ уходит в Jivo, журнал пишет in+out с latency', function () { +/** + * Мимикрирует то, что делает ChatController: пишет реплику клиента в журнал и + * запускает джобу с её номером, чтобы история не включала текущий вопрос дважды. + */ +function chatMessage(string $chatId, string $text): void +{ + $row = BotDialog::create([ + 'chat_id' => $chatId, + 'direction' => 'in', + 'message' => $text, + 'created_at' => now(), + ]); + + (new ProcessChatMessageJob($chatId, (int) $row->id, $text))->handle(); +} + +it('happy path: ответ пишется в журнал (in+out) с latency', function () { Http::fake([ 'llm.api.cloud.yandex.net/*' => Http::response([ 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]], ]), - 'bot.jivosite.com/*' => Http::response(['ok' => true]), ]); - (new ProcessJivoMessageJob('chat-1', 'client-1', 'что такое проект?'))->handle(); - - Http::assertSent(fn ($r) => str_contains($r->url(), 'bot.jivosite.com') && $r['event'] === 'BOT_MESSAGE'); + chatMessage('chat-1', 'что такое проект?'); $out = BotDialog::where('direction', 'out')->firstOrFail(); expect(BotDialog::where('direction', 'in')->count())->toBe(1) @@ -43,17 +55,14 @@ it('happy path: ответ уходит в Jivo, журнал пишет in+out ->and($out->matched_chunks)->not->toBeNull(); }); -it('эскалация: просьба телефона в чат, БЕЗ перевода на оператора, журнал escalated=true', function () { - // Правило владельца 12.07.2026: оператора в чате может не быть — бот собирает контакт +it('эскалация: просит телефон в чате, журнал escalated=true', function () { + // Правило владельца 12.07.2026: живого оператора в чате нет — бот собирает контакт // сам и отдаёт его поддержке письмом (см. BotContactCaptureTest). - Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]); + chatMessage('chat-2', 'какой у меня баланс?'); - (new ProcessJivoMessageJob('chat-2', 'client-2', 'какой у меня баланс?'))->handle(); - - Http::assertNotSent(fn ($r) => ($r['event'] ?? '') === 'INVITE_AGENT'); - Http::assertSent(fn ($r) => ($r['event'] ?? '') === 'BOT_MESSAGE' - && str_contains($r['message']['text'], 'номер телефона')); - expect(BotDialog::where('direction', 'out')->firstOrFail()->escalated)->toBeTrue(); + $out = BotDialog::where('direction', 'out')->firstOrFail(); + expect($out->message)->toContain('номер телефона') + ->and($out->escalated)->toBeTrue(); }); it('второе сообщение чата уходит в LLM вместе с историей первого', function () { @@ -62,11 +71,10 @@ it('второе сообщение чата уходит в LLM вместе с 'llm.api.cloud.yandex.net/*' => Http::response([ 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Конечно.']]]], ]), - 'bot.jivosite.com/*' => Http::response(['ok' => true]), ]); - (new ProcessJivoMessageJob('chat-h', 'client-1', 'что такое проект?'))->handle(); - (new ProcessJivoMessageJob('chat-h', 'client-1', 'а расскажи про проект подробнее'))->handle(); + chatMessage('chat-h', 'что такое проект?'); + chatMessage('chat-h', 'а расскажи про проект подробнее'); $llmCalls = collect(Http::recorded()) ->filter(fn ($pair) => str_contains($pair[0]->url(), 'llm.api.cloud.yandex.net')); @@ -82,11 +90,10 @@ it('история — только своего чата, чужие диало 'llm.api.cloud.yandex.net/*' => Http::response([ 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Ок.']]]], ]), - 'bot.jivosite.com/*' => Http::response(['ok' => true]), ]); - (new ProcessJivoMessageJob('chat-alien', 'client-9', 'что такое проект?'))->handle(); - (new ProcessJivoMessageJob('chat-mine', 'client-1', 'расскажи про проект'))->handle(); + chatMessage('chat-alien', 'что такое проект?'); + chatMessage('chat-mine', 'расскажи про проект'); $last = collect(collect(Http::recorded()) ->filter(fn ($pair) => str_contains($pair[0]->url(), 'llm.api.cloud.yandex.net')) @@ -96,7 +103,7 @@ it('история — только своего чата, чужие диало }); it('джоба объявлена с queue=bot и timeout ≤ 12 сек', function () { - $job = new ProcessJivoMessageJob('c', 'c', 'q'); + $job = new ProcessChatMessageJob('c', 1, 'q'); expect($job->queue)->toBe('bot') ->and($job->timeout)->toBeLessThanOrEqual(12); diff --git a/app/tests/Unit/Bot/JivoBotClientTest.php b/app/tests/Unit/Bot/JivoBotClientTest.php deleted file mode 100644 index 59389f03..00000000 --- a/app/tests/Unit/Bot/JivoBotClientTest.php +++ /dev/null @@ -1,45 +0,0 @@ -set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/prov-1/tok-1'); -}); - -it('BOT_MESSAGE уходит с chat_id/client_id и текстом', function () { - Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]); - - app(JivoBotClient::class)->sendMessage('chat-1', 'client-1', 'Проект — это…'); - - Http::assertSent(function ($request) { - return $request->url() === 'https://bot.jivosite.com/webhooks/prov-1/tok-1' - && $request['event'] === 'BOT_MESSAGE' - && $request['chat_id'] === 'chat-1' - && $request['client_id'] === 'client-1' - && $request['message']['type'] === 'TEXT' - && $request['message']['text'] === 'Проект — это…'; - }); -}); - -it('INVITE_AGENT уходит без message', function () { - Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]); - - app(JivoBotClient::class)->inviteAgent('chat-1', 'client-1'); - - Http::assertSent(fn ($r) => $r['event'] === 'INVITE_AGENT' && $r['chat_id'] === 'chat-1'); -}); - -it('пустой outbound_url (dev/CI) → ничего не шлёт и не падает', function () { - config()->set('services.jivo_bot.outbound_url', ''); - Http::fake(); - - app(JivoBotClient::class)->sendMessage('chat-1', 'client-1', 'x'); - - Http::assertNothingSent(); -});