chore(chat): убрать настройки Jivo — чат теперь свой; экскурсии выключены

Config: jivo_bot -> bot (убраны неиспользуемые webhook_secret/outbound_url/token
- транспорт Jivo уже удалён), jivosite убран целиком (виджет больше не нужен).
Env: JIVO_BOT_* -> BOT_*; JIVO_WIDGET_ID/VITE_JIVO_WIDGET_ID удалены.
tours_enabled выключен по умолчанию (BOT_TOURS_ENABLED=false) — экскурсии
«Показать на портале» уходили не по теме вопроса (живая проверка 13.07.2026),
чинить релевантность отдельно; сторож-тест не даёт включить незаметно.

Заодно убраны обнаруженные хвосты того же виджета, оставшиеся от прежних
задач: JivoLivenessProbe (класс+тест+регистрация в мониторинге внешних
сервисов), плитка «JivoSite» в админ-дашборде, встроенный скрипт виджета
в welcome.blade.php, осиротевший тест resources/js/.../JivoWidget.vue
(компонент уже был удалён ранее), declaration VITE_JIVO_WIDGET_ID.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-13 08:32:21 +03:00
parent ac0c8f74fc
commit e969b7700a
23 changed files with 50 additions and 157 deletions
+1 -5
View File
@@ -83,11 +83,7 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_EMAIL=support@liderra.ru
JIVO_WIDGET_ID=
JIVO_BOT_WEBHOOK_SECRET=
JIVO_BOT_OUTBOUND_URL=
JIVO_BOT_TOKEN=
JIVO_BOT_TOURS_ENABLED=false
BOT_TOURS_ENABLED=false
YANDEX_GPT_API_KEY=
YANDEX_GPT_FOLDER_ID=
-2
View File
@@ -9,7 +9,6 @@ use App\Services\Dashboard\BalanceHealth;
use App\Services\External\BalanceProvider;
use App\Services\External\CaptchaLivenessProbe;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\JivoLivenessProbe;
use App\Services\External\LivenessProbe;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\Yandex360BalanceProvider;
@@ -71,7 +70,6 @@ class RefreshExternalBalancesJob implements ShouldQueue
return [
app(YooKassaLivenessProbe::class),
app(JivoLivenessProbe::class),
app(CaptchaLivenessProbe::class),
];
}
+2 -2
View File
@@ -43,10 +43,10 @@ class AppServiceProvider extends ServiceProvider
SymfonyProcessFactory::class,
);
// Мозг бота: российская модель или зарубежная через шлюз AITunnel (services.jivo_bot.llm).
// Мозг бота: российская модель или зарубежная через шлюз AITunnel (services.bot.llm).
$this->app->bind(
LlmClient::class,
fn ($app) => config('services.jivo_bot.llm') === 'aitunnel'
fn ($app) => config('services.bot.llm') === 'aitunnel'
? $app->make(AitunnelChatClient::class)
: $app->make(YandexGptClient::class),
);
+2 -2
View File
@@ -36,10 +36,10 @@ class AitunnelChatClient implements LlmClient
$messages[] = ['role' => 'user', 'content' => $this->mask->hide($userText)];
try {
$response = Http::timeout((int) config('services.jivo_bot.timeout_seconds', 10))
$response = Http::timeout((int) config('services.bot.timeout_seconds', 10))
->withToken($key)
->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [
'model' => (string) config('services.jivo_bot.aitunnel_model', 'gpt-4o-mini'),
'model' => (string) config('services.bot.aitunnel_model', 'gpt-4o-mini'),
'temperature' => 0.2,
'max_tokens' => 500,
'messages' => $messages,
+1 -1
View File
@@ -301,7 +301,7 @@ class BotAnswerService
// Экскурсию предлагаем, только если статья реально по теме вопроса. Иначе клиент
// спрашивает про регистрацию, а ссылка ведёт на импорт данных (прогон 12.07.2026).
$tour = $this->search->isOnTopic($chunks[0], $question) ? $chunks[0]->tour : null;
if ($tour !== null && (bool) config('services.jivo_bot.tours_enabled')) {
if ($tour !== null && (bool) config('services.bot.tours_enabled')) {
$text .= "\n\n👉 Показать на портале: ".rtrim((string) config('app.url'), '/').'/?tour='.$tour;
}
+1 -1
View File
@@ -72,7 +72,7 @@ class ChatRateLimiter
private function store(): \Illuminate\Contracts\Cache\Repository
{
return Cache::store((string) config('services.jivo_bot.limiter_store'));
return Cache::store((string) config('services.bot.limiter_store'));
}
/** Атомарная прибавка с временем жизни. Возвращает новое значение счётчика. */
+1 -1
View File
@@ -8,7 +8,7 @@ namespace App\Services\Bot;
* Модель под капотом бота. Реализации: {@see YandexGptClient} (Яндекс, данные не покидают РФ)
* и {@see AitunnelChatClient} (зарубежные модели через российский шлюз AITunnel).
*
* Какая используется настройка `services.jivo_bot.llm` (yandex | aitunnel).
* Какая используется настройка `services.bot.llm` (yandex | aitunnel).
*/
interface LlmClient
{
-40
View File
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
use Illuminate\Support\Facades\Http;
/**
* Живость чата JivoSite: HTTP GET виджет-скрипта по widget_id. 200 жив.
* Нет widget_id grey. Денег не тратит (публичный статик).
*/
class JivoLivenessProbe implements LivenessProbe
{
public function serviceKey(): string
{
return 'jivosite';
}
public function check(): LivenessReading
{
$id = (string) config('services.jivosite.widget_id');
if ($id === '') {
return LivenessReading::unknown('jivosite', 'widget_id не задан');
}
try {
$tpl = (string) config('services.jivosite.widget_url_template', 'https://code.jivo.ru/widget/{id}');
$url = str_replace('{id}', $id, $tpl);
$resp = Http::timeout(5)->get($url);
if ($resp->ok()) {
return LivenessReading::alive('jivosite', 'виджет доступен');
}
return LivenessReading::down('jivosite', 'HTTP '.$resp->status());
} catch (\Throwable $e) {
return LivenessReading::down('jivosite', $e->getMessage());
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ namespace App\Services\External;
*/
interface LivenessProbe
{
/** email | yookassa | jivosite | captcha */
/** email | yookassa | captcha */
public function serviceKey(): string;
public function check(): LivenessReading;
+10 -17
View File
@@ -141,31 +141,24 @@ return [
'support' => [
'email' => env('SUPPORT_EMAIL', 'support@liderra.ru'),
],
'jivosite' => [
'widget_id' => env('JIVO_WIDGET_ID'),
'widget_url_template' => env('JIVO_WIDGET_URL_TEMPLATE', 'https://code.jivo.ru/widget/{id}'),
],
// Яндекс.Метрика портала (lk.liderra.ru). Пусто = Метрика не грузится.
// Вебвизор пишет только кабинет клиента (см. resources/js/plugins/metrika.ts + router).
'metrika' => [
'counter_id' => env('METRIKA_COUNTER_ID'),
],
// ИИ-бот техподдержки в чате Jivo (спека 2026-07-02-jivo-ai-support-bot-design).
// webhook_secret — входящий секрет в URL (≥32 симв., по образцу supplier webhook).
// outbound_url/token — выдаёт Jivo письмом при подключении Bot API; пусто → отправка
// событий отключена (dev/CI), бот пишет только в журнал.
'jivo_bot' => [
'webhook_secret' => env('JIVO_BOT_WEBHOOK_SECRET', ''),
'outbound_url' => env('JIVO_BOT_OUTBOUND_URL', ''),
'token' => env('JIVO_BOT_TOKEN', ''),
'tours_enabled' => env('JIVO_BOT_TOURS_ENABLED', false),
// ИИ-бот техподдержки в СВОЁМ окошке чата на сайте (спека 2026-07-13-own-chat-widget-design;
// до 07.07.2026 жил внутри чата Jivo — эта интеграция закрыта, см. docs/CHANGELOG).
// tours_enabled — «экскурсии» (ссылки «Показать на портале» в ответах бота): ВЫКЛЮЧЕНЫ
// по умолчанию, ссылки уходили не по теме вопроса (живая проверка 13.07.2026). Чинить
// релевантность — отдельно, механизм в коде остаётся.
'bot' => [
'tours_enabled' => env('BOT_TOURS_ENABLED', false),
// Мозг бота: yandex (данные не покидают РФ) | aitunnel (зарубежные модели через
// российский шлюз; ПДн вырезаются PersonalDataMask до отправки).
'llm' => env('JIVO_BOT_LLM', 'yandex'),
'aitunnel_model' => env('JIVO_BOT_AITUNNEL_MODEL', 'gpt-4o-mini'),
'timeout_seconds' => env('JIVO_BOT_LLM_TIMEOUT', 10),
'llm' => env('BOT_LLM', 'yandex'),
'aitunnel_model' => env('BOT_AITUNNEL_MODEL', 'gpt-4o-mini'),
'timeout_seconds' => env('BOT_LLM_TIMEOUT', 10),
// Счётчики чата должны считать АТОМАРНО: файловый кэш под параллельной нагрузкой
// теряет до 74% прибавок (проверено), и защита от накрутки счёта становится дырявой.
// Redis (INCR) атомарен. В тестах — 'array' (один процесс, гонки не бывает).
+1 -1
View File
@@ -21,7 +21,7 @@
</include>
</source>
<php>
<env name="JIVO_BOT_LLM" value="yandex"/>
<env name="BOT_LLM" value="yandex"/>
<env name="BOT_LIMITER_STORE" value="array"/>
<env name="AITUNNEL_API_KEY" value=""/>
<env name="APP_ENV" value="testing"/>
@@ -105,7 +105,6 @@ const SERVICE_LABELS: Record<string, string> = {
yandex_cloud: 'Yandex Cloud',
email: 'Почта',
yookassa: 'ЮKassa',
jivosite: 'JivoSite',
captcha: 'Капча',
};
const SERVICE_ICONS: Record<string, string> = {
@@ -114,7 +113,6 @@ const SERVICE_ICONS: Record<string, string> = {
yandex_cloud: '☁️',
email: '✉️',
yookassa: '💳',
jivosite: '💬',
captcha: '🛡',
};
@@ -131,7 +129,7 @@ function daysLeftLabel(days: number | null): string {
}
/** Сервисы БЕЗ денежного баланса — следим только за живостью (не за деньгами). */
const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'jivosite', 'captcha']);
const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'captcha']);
function isLivenessOnly(key: string): boolean {
return LIVENESS_ONLY_KEYS.has(key);
}
-1
View File
@@ -2,7 +2,6 @@
interface ImportMetaEnv {
readonly VITE_YANDEX_SMARTCAPTCHA_SITEKEY?: string;
readonly VITE_JIVO_WIDGET_ID?: string;
}
interface ImportMeta {
-3
View File
@@ -9,9 +9,6 @@
<meta name="metrika-id" content="{{ config('services.metrika.counter_id') }}">
@endif
@vite(['resources/css/app.css', 'resources/js/app.ts'])
@if(config('services.jivosite.widget_id'))
<script src="https://code.jivo.ru/widget/{{ config('services.jivosite.widget_id') }}" async></script>
@endif
</head>
<body>
<div id="app"></div>
@@ -88,7 +88,7 @@ it('LLM ответил «в инструкции этого нет» → чес
// Живой урок 12.07.2026: «а что такое объединить дубли?» → слабое совпадение поиска
// («что», «такое») → LLM честно сказал «В инструкции этого нет», а мы приляпали
// кнопку «Показать на портале». Незнание = эскалация, без кнопок.
config()->set('services.jivo_bot.tours_enabled', true);
config()->set('services.bot.tours_enabled', true);
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'В инструкции этого нет.']]]],
@@ -169,7 +169,7 @@ it('обычный вопрос → ответ LLM по контексту, бе
});
it('tour-ссылка добавляется только при включённом tours_enabled', function () {
config()->set('services.jivo_bot.tours_enabled', true);
config()->set('services.bot.tours_enabled', true);
config()->set('app.url', 'https://liderra.ru');
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
@@ -180,7 +180,7 @@ it('tour-ссылка добавляется только при включён
$withTours = app(BotAnswerService::class)->answer('что такое проект?');
expect($withTours->text)->toContain('https://liderra.ru/?tour=create-project');
config()->set('services.jivo_bot.tours_enabled', false);
config()->set('services.bot.tours_enabled', false);
$without = app(BotAnswerService::class)->answer('что такое проект?');
expect($without->text)->not->toContain('?tour=');
});
@@ -85,9 +85,9 @@ it('счётчики чата живут в атомарном хранилищ
// и защита от накрутки счёта становится дырявой. Хранилище счётчиков должно быть
// атомарным (Redis INCR). Сторож ловит и подмену на 'file', и пропажу самого ключа:
// без ключа config() вернул бы null, и проверка «не file» прошла бы впустую.
expect(config('services.jivo_bot.limiter_store'))->toBeIn(['redis', 'array']);
expect(config('services.bot.limiter_store'))->toBeIn(['redis', 'array']);
// И сам класс обязан брать это хранилище, а не общий кэш приложения.
expect(file_get_contents(app_path('Services/Bot/ChatRateLimiter.php')))
->toContain("Cache::store((string) config('services.jivo_bot.limiter_store'))");
->toContain("Cache::store((string) config('services.bot.limiter_store'))");
});
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
it('экскурсии «Показать на портале» пока выключены — ссылки не по теме', function () {
// Живая проверка 13.07.2026: бот приписывал «👉 Показать на портале: …?tour=support»
// к ответу не по теме вопроса. По спеке §10 в первом заходе экскурсий нет —
// чиним релевантность отдельно. Сторож не даёт включить их незаметно.
expect(config('services.bot.tours_enabled'))->toBeFalse();
});
+4 -4
View File
@@ -14,10 +14,10 @@ use Illuminate\Support\Facades\Http;
* клиент пишет в чат свой телефон. Маскируем до отправки.
*/
it('провайдер выбирается настройкой: яндекс или зарубежная модель через AITunnel', function () {
config()->set('services.jivo_bot.llm', 'yandex');
config()->set('services.bot.llm', 'yandex');
expect(app(LlmClient::class))->toBeInstanceOf(YandexGptClient::class);
config()->set('services.jivo_bot.llm', 'aitunnel');
config()->set('services.bot.llm', 'aitunnel');
expect(app(LlmClient::class))->toBeInstanceOf(AitunnelChatClient::class);
});
@@ -32,9 +32,9 @@ it('маскирует телефоны и почту до отправки в
});
it('зарубежная модель получает вопрос БЕЗ телефона клиента', function () {
config()->set('services.jivo_bot.llm', 'aitunnel');
config()->set('services.bot.llm', 'aitunnel');
config()->set('services.aitunnel.key', 'test-key');
config()->set('services.jivo_bot.aitunnel_model', 'claude-sonnet-5');
config()->set('services.bot.aitunnel_model', 'claude-sonnet-5');
Http::fake(['*' => Http::response([
'choices' => [['message' => ['content' => 'Передал специалисту.']]],
+1 -1
View File
@@ -327,7 +327,7 @@ it('не предлагает экскурсию, если статья подо
// «Показать на портале» на экскурсию не по теме — спросили про регистрацию,
// а бот вёл на импорт данных. Экскурсию даём, только если статья реально по теме:
// слово из вопроса попало в её заголовок или синонимы, а не просто в текст.
config()->set('services.jivo_bot.tours_enabled', true);
config()->set('services.bot.tours_enabled', true);
config()->set('app.url', 'https://liderra.ru');
KnowledgeChunk::query()->delete();
@@ -28,7 +28,7 @@ it('шлёт одно письмо, когда сервис впервые по
Mail::fake();
stubBalanceProvidersOk();
RefreshExternalBalancesJob::useLivenessProbes([
fakeProbe('jivosite', LivenessReading::down('jivosite', 'HTTP 500')),
fakeProbe('demo_probe', LivenessReading::down('demo_probe', 'HTTP 500')),
]);
(new RefreshExternalBalancesJob)->handle();
@@ -40,14 +40,14 @@ it('не шлёт письмо, если сервис уже был красны
Mail::fake();
stubBalanceProvidersOk();
// Предзаливаем строку: jivosite уже красный.
// Предзаливаем строку: demo_probe уже красный.
DB::connection('pgsql_supplier')->table('external_service_balances')->updateOrInsert(
['service_key' => 'jivosite'],
['service_key' => 'demo_probe'],
['light' => 'red', 'ok' => true, 'currency' => 'RUB', 'checked_at' => now(), 'updated_at' => now()],
);
RefreshExternalBalancesJob::useLivenessProbes([
fakeProbe('jivosite', LivenessReading::down('jivosite', 'HTTP 500')),
fakeProbe('demo_probe', LivenessReading::down('demo_probe', 'HTTP 500')),
]);
(new RefreshExternalBalancesJob)->handle();
@@ -59,7 +59,7 @@ it('не шлёт письмо, когда всё зелёное', function () {
Mail::fake();
stubBalanceProvidersOk();
RefreshExternalBalancesJob::useLivenessProbes([
fakeProbe('jivosite', LivenessReading::alive('jivosite', 'ок')),
fakeProbe('demo_probe', LivenessReading::alive('demo_probe', 'ок')),
]);
(new RefreshExternalBalancesJob)->handle();
@@ -85,7 +85,7 @@ it('пишет строки живости: balance_amount NULL, цвет из
app()->instance(Yandex360BalanceProvider::class, fakeProvider('email', BalanceReading::ok('email', 777, 'RUB', null)));
RefreshExternalBalancesJob::useLivenessProbes([
fakeProbe('jivosite', LivenessReading::down('jivosite', 'HTTP 500')),
fakeProbe('demo_probe', LivenessReading::down('demo_probe', 'HTTP 500')),
fakeProbe('captcha', LivenessReading::unknown('captcha', 'выключена')),
]);
@@ -95,9 +95,9 @@ it('пишет строки живости: balance_amount NULL, цвет из
expect($rows)->toHaveCount(6); // 4 деньги (вкл. email) + 2 живость
expect((float) $rows['email']->balance_amount)->toBe(777.0);
expect((bool) $rows['email']->ok)->toBeTrue();
expect($rows['jivosite']->light)->toBe('red');
expect((bool) $rows['jivosite']->ok)->toBeTrue(); // ok=true: статус свежий и определённый (упал)
expect($rows['jivosite']->error)->toContain('500');
expect($rows['demo_probe']->light)->toBe('red');
expect((bool) $rows['demo_probe']->ok)->toBeTrue(); // ok=true: статус свежий и определённый (упал)
expect($rows['demo_probe']->error)->toContain('500');
expect($rows['captcha']->light)->toBe('grey');
expect((bool) $rows['captcha']->ok)->toBeFalse(); // grey = не смогли/не применимо
});
-27
View File
@@ -1,27 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount } from '@vue/test-utils';
import JivoWidget from '../../resources/js/components/support/JivoWidget.vue';
beforeEach(() => {
document.querySelectorAll('#jivosite-widget-js').forEach((n) => n.remove());
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('JivoWidget', () => {
it('подгружает скрипт Jivo, когда задан VITE_JIVO_WIDGET_ID', () => {
vi.stubEnv('VITE_JIVO_WIDGET_ID', 'abc123');
mount(JivoWidget);
const script = document.getElementById('jivosite-widget-js') as HTMLScriptElement | null;
expect(script).not.toBeNull();
expect(script?.src).toContain('code.jivosite.com/widget/abc123');
});
it('ничего не грузит, когда ключ пуст (виджет спит)', () => {
vi.stubEnv('VITE_JIVO_WIDGET_ID', '');
mount(JivoWidget);
expect(document.getElementById('jivosite-widget-js')).toBeNull();
});
});
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\External\JivoLivenessProbe;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class); // нужен booted-app: config()/Http::fake()
beforeEach(function () {
config()->set('services.jivosite.widget_id', 'ABC123');
config()->set('services.jivosite.widget_url_template', 'https://code.jivo.ru/widget/{id}');
});
it('зелёный при 200 на виджет-скрипт', function () {
Http::fake(['code.jivo.ru/widget/ABC123' => Http::response('/* jivo */', 200)]);
$r = (new JivoLivenessProbe)->check();
expect($r->serviceKey)->toBe('jivosite');
expect($r->light)->toBe('green');
});
it('красный при 404', function () {
Http::fake(['code.jivo.ru/widget/ABC123' => Http::response('', 404)]);
expect((new JivoLivenessProbe)->check()->light)->toBe('red');
});
it('серый, когда widget_id не задан', function () {
config()->set('services.jivosite.widget_id', '');
expect((new JivoLivenessProbe)->check()->light)->toBe('grey');
});