diff --git a/app/.env.example b/app/.env.example index 3e9ee598..0b814c66 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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= diff --git a/app/app/Jobs/External/RefreshExternalBalancesJob.php b/app/app/Jobs/External/RefreshExternalBalancesJob.php index 5487b6ac..d117b854 100644 --- a/app/app/Jobs/External/RefreshExternalBalancesJob.php +++ b/app/app/Jobs/External/RefreshExternalBalancesJob.php @@ -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), ]; } diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index 7a62bbe1..fcce34ae 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -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), ); diff --git a/app/app/Services/Bot/AitunnelChatClient.php b/app/app/Services/Bot/AitunnelChatClient.php index d9e6dcab..0932ee06 100644 --- a/app/app/Services/Bot/AitunnelChatClient.php +++ b/app/app/Services/Bot/AitunnelChatClient.php @@ -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, diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index b83d458e..1d4a8f2b 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -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; } diff --git a/app/app/Services/Bot/ChatRateLimiter.php b/app/app/Services/Bot/ChatRateLimiter.php index cf53fda9..2b57e522 100644 --- a/app/app/Services/Bot/ChatRateLimiter.php +++ b/app/app/Services/Bot/ChatRateLimiter.php @@ -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')); } /** Атомарная прибавка с временем жизни. Возвращает новое значение счётчика. */ diff --git a/app/app/Services/Bot/LlmClient.php b/app/app/Services/Bot/LlmClient.php index 0b414e8a..15fb2403 100644 --- a/app/app/Services/Bot/LlmClient.php +++ b/app/app/Services/Bot/LlmClient.php @@ -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 { diff --git a/app/app/Services/External/JivoLivenessProbe.php b/app/app/Services/External/JivoLivenessProbe.php deleted file mode 100644 index 4352bb7b..00000000 --- a/app/app/Services/External/JivoLivenessProbe.php +++ /dev/null @@ -1,40 +0,0 @@ -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()); - } - } -} diff --git a/app/app/Services/External/LivenessProbe.php b/app/app/Services/External/LivenessProbe.php index bd04fc06..2ecdbe2a 100644 --- a/app/app/Services/External/LivenessProbe.php +++ b/app/app/Services/External/LivenessProbe.php @@ -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; diff --git a/app/config/services.php b/app/config/services.php index b4bec03d..b612dbbf 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -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' (один процесс, гонки не бывает). diff --git a/app/phpunit.xml b/app/phpunit.xml index 81280ea1..de1ebd01 100644 --- a/app/phpunit.xml +++ b/app/phpunit.xml @@ -21,7 +21,7 @@ - + diff --git a/app/resources/js/views/admin/AdminDashboardView.vue b/app/resources/js/views/admin/AdminDashboardView.vue index 33d49375..03d8cb7c 100644 --- a/app/resources/js/views/admin/AdminDashboardView.vue +++ b/app/resources/js/views/admin/AdminDashboardView.vue @@ -105,7 +105,6 @@ const SERVICE_LABELS: Record = { yandex_cloud: 'Yandex Cloud', email: 'Почта', yookassa: 'ЮKassa', - jivosite: 'JivoSite', captcha: 'Капча', }; const SERVICE_ICONS: Record = { @@ -114,7 +113,6 @@ const SERVICE_ICONS: Record = { 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); } diff --git a/app/resources/js/vite-env.d.ts b/app/resources/js/vite-env.d.ts index 68901b6d..53911541 100644 --- a/app/resources/js/vite-env.d.ts +++ b/app/resources/js/vite-env.d.ts @@ -2,7 +2,6 @@ interface ImportMetaEnv { readonly VITE_YANDEX_SMARTCAPTCHA_SITEKEY?: string; - readonly VITE_JIVO_WIDGET_ID?: string; } interface ImportMeta { diff --git a/app/resources/views/welcome.blade.php b/app/resources/views/welcome.blade.php index 24b454cb..8aa6f1df 100644 --- a/app/resources/views/welcome.blade.php +++ b/app/resources/views/welcome.blade.php @@ -9,9 +9,6 @@ @endif @vite(['resources/css/app.css', 'resources/js/app.ts']) - @if(config('services.jivosite.widget_id')) - - @endif
diff --git a/app/tests/Feature/Bot/BotAnswerServiceTest.php b/app/tests/Feature/Bot/BotAnswerServiceTest.php index a69528c1..164de2b9 100644 --- a/app/tests/Feature/Bot/BotAnswerServiceTest.php +++ b/app/tests/Feature/Bot/BotAnswerServiceTest.php @@ -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='); }); diff --git a/app/tests/Feature/Bot/ChatRateLimiterTest.php b/app/tests/Feature/Bot/ChatRateLimiterTest.php index 787742e9..a70f5bac 100644 --- a/app/tests/Feature/Bot/ChatRateLimiterTest.php +++ b/app/tests/Feature/Bot/ChatRateLimiterTest.php @@ -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'))"); }); diff --git a/app/tests/Feature/Bot/ChatToursOffTest.php b/app/tests/Feature/Bot/ChatToursOffTest.php new file mode 100644 index 00000000..0ddbb0f7 --- /dev/null +++ b/app/tests/Feature/Bot/ChatToursOffTest.php @@ -0,0 +1,10 @@ +toBeFalse(); +}); diff --git a/app/tests/Feature/Bot/LlmProviderTest.php b/app/tests/Feature/Bot/LlmProviderTest.php index f668a5ca..e0c01d73 100644 --- a/app/tests/Feature/Bot/LlmProviderTest.php +++ b/app/tests/Feature/Bot/LlmProviderTest.php @@ -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' => 'Передал специалисту.']]], diff --git a/app/tests/Feature/Bot/MassRunFixesTest.php b/app/tests/Feature/Bot/MassRunFixesTest.php index b7c5b8d3..5a77b202 100644 --- a/app/tests/Feature/Bot/MassRunFixesTest.php +++ b/app/tests/Feature/Bot/MassRunFixesTest.php @@ -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(); diff --git a/app/tests/Feature/External/ExternalServiceDownAlertTest.php b/app/tests/Feature/External/ExternalServiceDownAlertTest.php index 0a296abc..ee5fdc5b 100644 --- a/app/tests/Feature/External/ExternalServiceDownAlertTest.php +++ b/app/tests/Feature/External/ExternalServiceDownAlertTest.php @@ -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(); diff --git a/app/tests/Feature/External/RefreshExternalBalancesJobTest.php b/app/tests/Feature/External/RefreshExternalBalancesJobTest.php index d33c4a28..4ca2c959 100644 --- a/app/tests/Feature/External/RefreshExternalBalancesJobTest.php +++ b/app/tests/Feature/External/RefreshExternalBalancesJobTest.php @@ -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 = не смогли/не применимо }); diff --git a/app/tests/Frontend/JivoWidget.spec.ts b/app/tests/Frontend/JivoWidget.spec.ts deleted file mode 100644 index 98a7a6f7..00000000 --- a/app/tests/Frontend/JivoWidget.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/tests/Unit/External/JivoLivenessProbeTest.php b/app/tests/Unit/External/JivoLivenessProbeTest.php deleted file mode 100644 index a7fd5478..00000000 --- a/app/tests/Unit/External/JivoLivenessProbeTest.php +++ /dev/null @@ -1,31 +0,0 @@ -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'); -});