diff --git a/app/app/Jobs/Bot/ProcessJivoMessageJob.php b/app/app/Jobs/Bot/ProcessJivoMessageJob.php index 2f55da7b..ea006042 100644 --- a/app/app/Jobs/Bot/ProcessJivoMessageJob.php +++ b/app/app/Jobs/Bot/ProcessJivoMessageJob.php @@ -4,17 +4,25 @@ declare(strict_types=1); namespace App\Jobs\Bot; +use App\Mail\BotContactRequestMail; 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\Mail; /** * Оркестратор ответа бота (спека §2). Очередь `bot` — отдельный worker на проде, * чтобы поток лидов не задерживал ответы чата (скорость — требование №1). * timeout 12с < 15с Jivo-страховки: не успели — Jivo сам позовёт оператора. - * $tries=1: ретраить разговор бессмысленно, клиент уже у живого оператора. + * $tries=1: ретраить разговор бессмысленно. + * + * Эскалация (правило владельца 12.07.2026): бот НЕ переводит чат на оператора + * (INVITE_AGENT) — оператора в чате может не быть. Вместо этого он просит телефон; + * как только клиент его оставил, контакт уходит письмом в поддержку. */ class ProcessJivoMessageJob implements ShouldQueue { @@ -61,13 +69,11 @@ class ProcessJivoMessageJob implements ShouldQueue 'created_at' => now(), ]); - $answer = app(BotAnswerService::class)->answer($this->text, $history); - $jivo = app(JivoBotClient::class); + $answer = $this->awaitsContact() + ? $this->captureContact() + : app(BotAnswerService::class)->answer($this->text, $history); - $jivo->sendMessage($this->chatId, $this->clientId, $answer->text); - if ($answer->escalate) { - $jivo->inviteAgent($this->chatId, $this->clientId); - } + app(JivoBotClient::class)->sendMessage($this->chatId, $this->clientId, $answer->text); BotDialog::create([ 'jivo_chat_id' => $this->chatId, @@ -79,4 +85,53 @@ class ProcessJivoMessageJob implements ShouldQueue 'created_at' => now(), ]); } + + /** Ждём ли мы от клиента телефон: последний ответ бота был просьбой оставить номер. */ + private function awaitsContact(): bool + { + $lastOut = BotDialog::query() + ->where('jivo_chat_id', $this->chatId) + ->where('direction', 'out') + ->latest('id') + ->value('message'); + + return is_string($lastOut) && str_contains($lastOut, 'номер телефона'); + } + + /** + * Клиент ответил на просьбу о телефоне. Номер есть — шлём контакт письмом + * в поддержку; номера нет (передумал, задал новый вопрос) — отвечаем как обычно. + */ + private function captureContact(): BotAnswer + { + $phone = app(ContactCapture::class)->extractPhone($this->text); + if ($phone === null) { + return app(BotAnswerService::class)->answer($this->text); + } + + $rows = BotDialog::query() + ->where('jivo_chat_id', $this->chatId) + ->orderByDesc('id') + ->limit(8) + ->get(['direction', 'message']) + ->reverse() + ->values(); + + // Вопрос, на котором бот спасовал, — последняя реплика клиента ПЕРЕД просьбой о номере. + $question = $rows + ->filter(fn (BotDialog $d) => $d->direction === 'in') + ->slice(0, -1) + ->last()?->message ?? $this->text; + + $transcript = $rows + ->map(fn (BotDialog $d) => ($d->direction === 'in' ? 'Клиент: ' : 'Бот: ').$d->message) + ->all(); + + $to = (string) config('services.support.email'); + if ($to !== '') { + Mail::to($to)->send(new BotContactRequestMail($phone, $question, $this->chatId, $transcript)); + } + + return new BotAnswer(BotAnswerService::CONTACT_TAKEN_TEXT, escalate: true); + } } diff --git a/app/app/Mail/BotContactRequestMail.php b/app/app/Mail/BotContactRequestMail.php new file mode 100644 index 00000000..0ea8f84f --- /dev/null +++ b/app/app/Mail/BotContactRequestMail.php @@ -0,0 +1,54 @@ + $transcript последние реплики чата («Клиент: …», «Бот: …») + */ + public function __construct( + public readonly string $phone, + public readonly string $question, + public readonly string $chatId, + public readonly array $transcript = [], + ) {} + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Лидерра. Клиент из чата просит связаться — '.$this->phone, + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.bot_contact_request', + with: [ + 'phone' => $this->phone, + 'question' => $this->question, + 'chatId' => $this->chatId, + 'transcript' => $this->transcript, + ], + ); + } +} diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index ff211f1b..a0ed1ba5 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -39,9 +39,18 @@ class BotAnswerService */ private const HOWTO_PATTERN = '/(перенест|перенос|импорт|загрузи|выгрузи|экспорт|скача|как\s+(создать|поменять|сменить|настроить|изменить|добавить|удалить))/iu'; - private const ESCALATE_TEXT = 'Этот вопрос лучше разберёт живой специалист — передаю ему диалог. Он ответит здесь же.'; + /** + * Эскалация (правило владельца 12.07.2026): бот не переводит чат на оператора — + * оператор не сидит в чате круглосуточно. Бот ПРОСИТ телефон и присылает контакт + * письмом, чтобы человек перезвонил. Тексты содержат маркер «номер телефона» — + * по нему ProcessJivoMessageJob понимает, что ждёт контакт. + */ + public const ESCALATE_TEXT = 'Этот вопрос лучше разберёт живой специалист. Оставьте, пожалуйста, ваш номер телефона — специалист свяжется с вами и всё подскажет.'; - private const UNKNOWN_TEXT = 'Честно — в моей инструкции нет ответа на этот вопрос. Передаю живому специалисту, он ответит здесь же.'; + public const UNKNOWN_TEXT = 'Честно — в моей инструкции нет ответа на этот вопрос. Оставьте, пожалуйста, ваш номер телефона — специалист свяжется с вами и ответит.'; + + /** Подтверждение после того, как клиент оставил телефон. */ + public const CONTACT_TAKEN_TEXT = 'Спасибо! Передал ваш номер специалисту — он свяжется с вами в ближайшее время. Если есть ещё вопросы по порталу — спрашивайте, я на связи.'; public function __construct( private readonly KnowledgeSearch $search, diff --git a/app/app/Services/Bot/ContactCapture.php b/app/app/Services/Bot/ContactCapture.php new file mode 100644 index 00000000..3dd0d3c9 --- /dev/null +++ b/app/app/Services/Bot/ContactCapture.php @@ -0,0 +1,35 @@ +Клиент писал в чат Лидерры, бот не смог ответить сам и попросил телефон для связи.

+ +@if (count($transcript) > 0) +

Переписка:

+ +@endif +

Перезвоните клиенту — он ждёт ответа.

diff --git a/app/tests/Feature/Bot/BotAnswerServiceTest.php b/app/tests/Feature/Bot/BotAnswerServiceTest.php index 1423097a..a69528c1 100644 --- a/app/tests/Feature/Bot/BotAnswerServiceTest.php +++ b/app/tests/Feature/Bot/BotAnswerServiceTest.php @@ -27,10 +27,12 @@ it('стоп-тема (мой баланс) → эскалация без пох $answer = app(BotAnswerService::class)->answer('какой у меня баланс?'); - // Текст именно стоп-эскалации («передаю ему диалог»), а не «в инструкции нет ответа»: - // до фикса 12.07.2026 вопрос проскакивал стоп-гейт и эскалировал лишь пустым поиском. + // Текст именно стоп-эскалации («лучше разберёт живой специалист»), а не «в инструкции + // нет ответа»: до фикса 12.07.2026 вопрос проскакивал стоп-гейт и эскалировал лишь + // пустым поиском. Эскалация просит телефон (правило владельца), а не зовёт оператора. expect($answer->escalate)->toBeTrue() - ->and($answer->text)->toContain('передаю ему диалог'); + ->and($answer->text)->toContain('лучше разберёт живой специалист') + ->and($answer->text)->toContain('номер телефона'); Http::assertNothingSent(); }); @@ -48,7 +50,7 @@ it('стоп-тема со словами между «у меня» и «бал $answer = app(BotAnswerService::class)->answer('какой у меня сейчас баланс?'); expect($answer->escalate)->toBeTrue() - ->and($answer->text)->toContain('передаю ему диалог'); + ->and($answer->text)->toContain('лучше разберёт живой специалист'); Http::assertNothingSent(); }); @@ -97,7 +99,8 @@ it('LLM ответил «в инструкции этого нет» → чес expect($answer->escalate)->toBeTrue() ->and($answer->text)->not->toContain('Показать на портале') - ->and($answer->text)->toContain('живому специалисту'); + ->and($answer->text)->toContain('в моей инструкции нет ответа') + ->and($answer->text)->toContain('номер телефона'); }); it('вопрос про эксклюзивность заявок → эскалация без похода в LLM', function () { diff --git a/app/tests/Feature/Bot/BotContactCaptureTest.php b/app/tests/Feature/Bot/BotContactCaptureTest.php new file mode 100644 index 00000000..708b5431 --- /dev/null +++ b/app/tests/Feature/Bot/BotContactCaptureTest.php @@ -0,0 +1,84 @@ +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', + 'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion', + 'timeout_seconds' => 8, + ]); + KnowledgeChunk::create([ + 'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => null, + 'topics' => 'создать проект', 'chunk_index' => 0, + 'content' => 'Проект — это заявка на поток клиентов.', + ]); + Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]); +}); + +it('эскалация просит телефон, а не зовёт оператора в чат', function () { + Mail::fake(); + + (new ProcessJivoMessageJob('chat-c1', 'client-1', 'какой у меня баланс?'))->handle(); + + $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(); + + Mail::assertSent(BotContactRequestMail::class, function (BotContactRequestMail $mail) { + return $mail->hasTo('support@liderra.ru') + && $mail->phone === '79161234567' + && str_contains($mail->question, 'баланс'); + }); + + $last = BotDialog::where('direction', 'out')->latest('id')->firstOrFail(); + expect($last->message)->toContain('Спасибо'); +}); + +it('телефон без предшествующей просьбы бота письмо не шлёт', function () { + Mail::fake(); + + (new ProcessJivoMessageJob('chat-c3', 'client-3', 'звоните мне на +79161234567 если что'))->handle(); + + Mail::assertNothingSent(); +}); + +it('номер распознаётся в разных форматах', function () { + Mail::fake(); + + $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(); + } + + Mail::assertSent(BotContactRequestMail::class, 3); + Mail::assertSent(BotContactRequestMail::class, fn ($m) => $m->phone === '79161234567'); +}); diff --git a/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php b/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php index 138f84f3..9185dd45 100644 --- a/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php +++ b/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php @@ -43,12 +43,16 @@ it('happy path: ответ уходит в Jivo, журнал пишет in+out ->and($out->matched_chunks)->not->toBeNull(); }); -it('эскалация: BOT_MESSAGE-прощание + INVITE_AGENT, журнал escalated=true', function () { +it('эскалация: просьба телефона в чат, БЕЗ перевода на оператора, журнал escalated=true', function () { + // Правило владельца 12.07.2026: оператора в чате может не быть — бот собирает контакт + // сам и отдаёт его поддержке письмом (см. BotContactCaptureTest). Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]); (new ProcessJivoMessageJob('chat-2', 'client-2', 'какой у меня баланс?'))->handle(); - Http::assertSent(fn ($r) => ($r['event'] ?? '') === 'INVITE_AGENT'); + 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(); });