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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user