feat(jivo-bot): эскалация = просьба телефона + письмо с контактом в поддержку

Правило владельца 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 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-12 11:30:33 +03:00
parent d6526bdadf
commit 6f72367b0f
8 changed files with 276 additions and 16 deletions
+62 -7
View File
@@ -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);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Контакт клиента из чата бота: бот не смог ответить (личный вопрос, стоп-тема,
* незнание) попросил телефон клиент оставил. Письмо уходит в поддержку
* (config services.support.email), чтобы человек перезвонил.
*/
class BotContactRequestMail extends Mailable
{
use Queueable;
use SerializesModels;
/**
* @param string $phone нормализованный номер (79161234567)
* @param string $question вопрос, на котором бот спасовал
* @param list<string> $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,
],
);
}
}
+11 -2
View File
@@ -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,
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
/**
* Распознавание телефона в сообщении клиента (правило владельца 12.07.2026:
* бот, который не смог ответить, просит номер и передаёт контакт письмом).
* Принимаем любые человеческие форматы: «+7 916 123-45-67», «8(916)1234567», «79161234567».
*/
class ContactCapture
{
/** Российский номер: 10 значащих цифр после кода страны 7/8 (или без кода). */
private const PHONE_PATTERN = '/(?:\+?[78][\s\-()]*)?(\d[\s\-()]*){10}/u';
/** Нормализованный номер (79161234567) или null, если телефона в тексте нет. */
public function extractPhone(string $text): ?string
{
if (preg_match(self::PHONE_PATTERN, $text, $m) !== 1) {
return null;
}
$digits = preg_replace('/\D+/', '', $m[0]) ?? '';
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
return '7'.substr($digits, 1);
}
if (strlen($digits) === 10) {
return '7'.$digits;
}
return null;
}
}
@@ -0,0 +1,16 @@
<p>Клиент писал в чат Лидерры, бот не смог ответить сам и попросил телефон для связи.</p>
<ul>
<li><strong>Телефон клиента:</strong> {{ $phone }}</li>
<li><strong>Вопрос клиента:</strong> {{ $question }}</li>
<li><strong>Чат:</strong> {{ $chatId }}</li>
<li><strong>Время:</strong> {{ now()->format('d.m.Y H:i') }} МСК</li>
</ul>
@if (count($transcript) > 0)
<p><strong>Переписка:</strong></p>
<ul>
@foreach ($transcript as $line)
<li>{{ $line }}</li>
@endforeach
</ul>
@endif
<p>Перезвоните клиенту он ждёт ответа.</p>
@@ -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 () {
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use App\Jobs\Bot\ProcessJivoMessageJob;
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);
/**
* Правило владельца 12.07.2026: бот не «передаёт диалог оператору» (оператор
* не сидит в чате круглосуточно), а ПРОСИТ телефон и присылает контакт письмом.
*/
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',
'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');
});
@@ -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();
});