From f33d1a1638abb0cace4e07925e00f4f7b85d1257 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: Sun, 12 Jul 2026 11:30:33 +0300
Subject: [PATCH] =?UTF-8?q?feat(jivo-bot):=20=D1=8D=D1=81=D0=BA=D0=B0?=
=?UTF-8?q?=D0=BB=D0=B0=D1=86=D0=B8=D1=8F=20=3D=20=D0=BF=D1=80=D0=BE=D1=81?=
=?UTF-8?q?=D1=8C=D0=B1=D0=B0=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD?=
=?UTF-8?q?=D0=B0=20+=20=D0=BF=D0=B8=D1=81=D1=8C=D0=BC=D0=BE=20=D1=81=20?=
=?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B0=D0=BA=D1=82=D0=BE=D0=BC=20=D0=B2?=
=?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D1=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Правило владельца 12.07.2026: оператора в чате может не быть, поэтому бот НЕ
переводит диалог (INVITE_AGENT убран из авто-флоу). Не смог ответить (личный
вопрос, стоп-тема, незнание) → просит номер телефона → как только клиент его
оставил, контакт уходит письмом в поддержку, а клиенту — «Спасибо, передал».
- ContactCapture: распознаёт номер в любом формате (+7/8/скобки/дефисы) → 79161234567
- BotContactRequestMail + шаблон: телефон, вопрос-на-котором-спасовал, id чата,
переписка целиком, время → services.support.email
- ProcessJivoMessageJob: ждём контакт, если прошлый ответ бота просил номер;
номера нет (клиент передумал/спросил другое) — отвечаем как обычно
- тексты эскалации переписаны под сбор контакта
TDD: BotContactCaptureTest (4 новых) + обновлены 4 теста прежней механики. 46/46.
Живая проверка на стенде: вопрос → просьба → номер → письмо с полной перепиской.
Co-Authored-By: Claude Fable 5
---
app/app/Jobs/Bot/ProcessJivoMessageJob.php | 69 +++++++++++++--
app/app/Mail/BotContactRequestMail.php | 54 ++++++++++++
app/app/Services/Bot/BotAnswerService.php | 13 ++-
app/app/Services/Bot/ContactCapture.php | 35 ++++++++
.../emails/bot_contact_request.blade.php | 16 ++++
.../Feature/Bot/BotAnswerServiceTest.php | 13 +--
.../Feature/Bot/BotContactCaptureTest.php | 84 +++++++++++++++++++
.../Feature/Bot/ProcessJivoMessageJobTest.php | 8 +-
8 files changed, 276 insertions(+), 16 deletions(-)
create mode 100644 app/app/Mail/BotContactRequestMail.php
create mode 100644 app/app/Services/Bot/ContactCapture.php
create mode 100644 app/resources/views/emails/bot_contact_request.blade.php
create mode 100644 app/tests/Feature/Bot/BotContactCaptureTest.php
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 @@
+Клиент писал в чат Лидерры, бот не смог ответить сам и попросил телефон для связи.
+
+ - Телефон клиента: {{ $phone }}
+ - Вопрос клиента: {{ $question }}
+ - Чат: {{ $chatId }}
+ - Время: {{ now()->format('d.m.Y H:i') }} МСК
+
+@if (count($transcript) > 0)
+ Переписка:
+
+ @foreach ($transcript as $line)
+ - {{ $line }}
+ @endforeach
+
+@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();
});