Files
portal/app/config/services.php
T
Дмитрий dd3b6fb4b5 fix обзвон: к роботу только по шифрованной дороге, кроме своей машины — Р123
Дверь пускала http к разрешённому хосту, и тайный ключ уезжал заголовком
открытым текстом — молча, без единого признака. Одной опечатки в OBZVON_ENDPOINT
хватало. Теперь адрес без шифрования портал не принимает: дверь закрыта, звонка
нет, причина сказана словами и лежит в журнале уровнем предупреждения.

Исключение ровно одно — разработчик на своём компьютере — и устроено оно ДВУМЯ
независимыми ответами, чтобы одной перепутанной настройки на боевом для утечки
не хватило:

1. приложение само говорит, где живёт: спрашиваем Laravel, а не список слов.
   Список окружений положительный, а не «всё кроме боевого»: любое неназванное
   окружение обязано шифрование.
2. робот стоит на ЭТОЙ ЖЕ машине: адрес ведёт на петлю — вся сеть 127.0.0.0/8,
   ::1, ::ffff:127.x.x.x, имена localhost и *.localhost по RFC 6761. Определяется
   двоичным сравнением адреса, а не совпадением слов. Резолвер имён не зовём
   намеренно: он вносит в проверку безопасности неопределённость и щель между
   «проверили» и «пошли».

Именно второй ответ и закрывает то, что владелец назвал незакрытым: одной
ошибки в APP_ENV на боевом теперь мало — боевой робот петлёй не станет. На это
стоит именной сторож.

Тумблера «можно без шифра» нет намеренно: он и был бы той опечаткой, ради
которой Р123 принято.

12 новых сторожей: четыре случая ножа приёмки ж, з, и, к; сторож на само
исключение; сторож против совпадения слов вместо петли; сторож на подложное
шифрование, когда проверку чужого удостоверения снимают. Каждый показан
красным — починка ронялась четырьмя разными способами, и каждый раз краснело
именно то, что этот способ ломает. Возврат доказан слепком со снятием невидимых
знаков конца строки.

Моя ошибка первой: первый сторож про журнал зеленел не по той причине — любая
строка предупреждения его устраивала. Нашёлся только арифметикой прогона, потому
что упал в поле ошибок, а не провалов. Переписан.

Найдено и НЕ тронуто второе место того же класса: services.self_render шлёт
секрет заголовком, и его дверь не проверяет ни схему, ни хост вовсе. Путь живой,
боевой. Вынесено решением владельцу отдельно.

Отчёт: docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-r123-shifrovanie-2026-08-07.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 06:31:35 +03:00

507 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
// Канал робота-грузчика креативов в веб-кабинет Яндекса. Свой токен, отдельный
// от клиентских и от токена «Поиск → Портал». Пусто → канал закрыт.
'creative_robot' => [
'token' => env('CREATIVE_ROBOT_TOKEN', ''),
],
// Служебный канал «Телеграм-робот кабинета МТС → Портал». Пусто → канал закрыт (401).
'tg_robot' => [
'token' => env('TG_ROBOT_TOKEN', ''),
],
// Рекламная аудитория кандидатов (Яндекс.Аудитории). Токен — OAuth Яндекс ID,
// выдаётся разово на аккаунт, где живёт сегмент. Пусто → синхронизация не идёт,
// джоб пишет причину в last_error и молчит.
'yandex_audience' => [
'token' => env('YANDEX_AUDIENCE_TOKEN'),
'segment_name' => env('YANDEX_AUDIENCE_SEGMENT_NAME', 'Лидерра — кандидаты'),
],
// Яндекс.Директ (B1) — рекламный кошелёк. ПЕСОЧНИЦА по умолчанию (В8): боевой —
// сменой env на api.direct.yandex.com после одобрения заявки. Денежный рубильник
// enabled: пока выкл — реальные вызовы Директа не идут (как enabled у прогрева).
'yandex_direct' => [
'base_url' => env('YANDEX_DIRECT_BASE_URL', 'https://api-sandbox.direct.yandex.com'),
'token' => env('YANDEX_DIRECT_TOKEN'),
'enabled' => env('YANDEX_DIRECT_ENABLED', false),
// Регионы показа по умолчанию (вся Россия = 225).
'region_ids' => [225],
// Предохранитель: SpendLimit в Яндексе = яндекс-бюджет × множитель (бэкстоп
// от перерасхода, НЕ клиентская цена).
'spend_limit_guard_multiplier' => (float) env('YANDEX_DIRECT_SPEND_GUARD', 1.2),
// Минимальный бюджет кампании за период, ниже которого Директ её просто не берёт.
// Порог НЕ постоянный — он растёт с длиной периода: 300 ₽ за каждый календарный
// день, считая оба края. Два живых отказа 30.07.2026 дали обе точки:
// период 30.0731.07 (2 дня) → «cannot be less than 600 rub.» = 2 × 300
// период 30.0706.08 (8 дней) → «cannot be less than 2400 rub.» = 8 × 300
// Первый отказ мы приняли за постоянный порог — и проверка пропускала любую
// кампанию длиннее двух дней прямо в тот же английский отказ Яндекса.
// В настройке, а не в коде: Яндекс может поменять ставку.
'min_spend_limit_rub_per_day' => (string) env('YANDEX_DIRECT_MIN_SPEND_RUB_PER_DAY', '300'),
],
// Рекламная аудитория кандидатов в ВК (Task 8, 21.07.2026). Программный доступ
// запрошен 19.07, ждём проверки реквизитов — до получения токена джоб пишет
// vk_status=no_access и не делает ни одного обращения к ВК.
'vk_ads' => [
'token' => env('VK_ADS_TOKEN', ''),
],
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
// Капча самозаписи (G1/SP1). driver=null → NullCaptchaVerifier (dev/test).
// Реальный Yandex SmartCaptcha подключается позже (SP3/ops).
'captcha' => [
'driver' => env('CAPTCHA_DRIVER', 'null'),
'fake_passes' => filter_var(env('CAPTCHA_FAKE_PASSES', true), FILTER_VALIDATE_BOOL),
'yandex_server_key' => env('YANDEX_SMARTCAPTCHA_SERVER_KEY'),
'yandex_validate_url' => env('YANDEX_SMARTCAPTCHA_VALIDATE_URL', 'https://smartcaptcha.cloud.yandex.ru/validate'),
],
'supplier' => [
'login' => env('SUPPLIER_LOGIN'),
'password' => env('SUPPLIER_PASSWORD'),
'portal_url' => env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru'),
'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'),
// Плитка балансов (28.06): у кабинета поставщика НЕ денежный баланс, а остаток
// НОМЕРОВ («Баланс ГЦК» в выпадашке). Деньги = номера × number_price_rub (20 ₽/шт,
// подтверждено владельцем 28.06). avg_lead_price_rub=0 → расход/день неизвестен.
'number_price_rub' => (float) env('SUPPLIER_NUMBER_PRICE_RUB', 20),
'avg_lead_price_rub' => (float) env('SUPPLIER_AVG_LEAD_PRICE_RUB', 0),
'red_floor_rub' => (int) env('SUPPLIER_RED_FLOOR_RUB', 5000),
'amber_floor_rub' => (int) env('SUPPLIER_AMBER_FLOOR_RUB', 15000),
'topup_url' => env('SUPPLIER_TOPUP_URL', env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru')),
// Рубильник ежедневной чистки доп-каналов (B6/B8/…). По умолчанию ВКЛ.
// Аварийное отключение: SUPPLIER_PRUNE_EXTRA_CHANNELS_ENABLED=false + рестарт воркеров.
'prune_extra_channels' => filter_var(env('SUPPLIER_PRUNE_EXTRA_CHANNELS_ENABLED', true), FILTER_VALIDATE_BOOL),
],
// DaData phone cleaner — резолв региона лида по телефону (lead region resolution).
// Ключи → YC Lockbox на проде; на dev/staging — .env. enabled=false до раскатки.
'dadata' => [
'api_key' => env('DADATA_API_KEY'),
'secret' => env('DADATA_SECRET'),
'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000),
'retries' => (int) env('DADATA_RETRIES', 1),
'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000),
'call_cost_kopecks' => (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу
'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
// G1/SP2: подтяжка организации по ИНН (suggestions findById/party). Тот же api_key
// (Token), secret не нужен. Default false → NullPartyLookup (dev/тесты не ходят в сеть).
'party_enabled' => filter_var(env('DADATA_PARTY_ENABLED', false), FILTER_VALIDATE_BOOL),
// Плитка балансов (28.06): чтение баланса профиля + пороги светофора + ссылка пополнения.
'balance_url' => env('DADATA_BALANCE_URL', 'https://dadata.ru/api/v2/profile/balance'),
'red_floor_rub' => (int) env('DADATA_RED_FLOOR_RUB', 500),
'amber_floor_rub' => (int) env('DADATA_AMBER_FLOOR_RUB', 2000),
'topup_url' => env('DADATA_TOPUP_URL', 'https://dadata.ru/profile/#billing'),
],
// СМС-центр (smsc.ru) — HLR-проверка номеров в Поиске клиентов (жив/несуществует).
// Баланс на плитке присмотра. Логин/пароль — те же, что у службы поиска.
'smsc' => [
'login' => env('SMSC_LOGIN'),
'password' => env('SMSC_PASSWORD'),
'balance_url' => env('SMSC_BALANCE_URL', 'https://smsc.ru/sys/balance.php'),
'red_floor_rub' => (int) env('SMSC_RED_FLOOR_RUB', 300),
'amber_floor_rub' => (int) env('SMSC_AMBER_FLOOR_RUB', 1000),
'topup_url' => env('SMSC_TOPUP_URL', 'https://smsc.ru/oplata/'),
// Своё согласованное имя отправителя универсального канала. Подставляется,
// когда своего имени у клиента для сети получателя нет: имя согласовывает
// ОПЕРАТОР, а универсал ходит во все сети сразу (см. ClientSmsSenderResolver).
// Пусто — уйдёт без имени, как было до 05.08.2026.
'naming' => env('SMSC_NAMING', ''),
],
// AITUNNEL (OpenAI-совместимый) для автоподбора: эмбеддинги (похожесть конкурента на
// профиль клиента) + LLM-классификатор «поставщик услуги или площадка-агрегатор?».
// Ключ — только в .env/окружении, НИКОГДА в гит. Пусто → движок отдаёт сырой список
// (без % и без авто-отсева площадок).
'aitunnel' => [
'key' => env('AITUNNEL_API_KEY'),
'base_url' => env('AITUNNEL_BASE_URL', 'https://api.aitunnel.ru/v1'),
'embed_model' => env('AITUNNEL_EMBED_MODEL', 'text-embedding-3-small'),
'chat_model' => env('AITUNNEL_CHAT_MODEL', 'gpt-4o-mini'),
// Модель отсева агрегаторов (вариант А — точнее, чтобы не терять живых конкурентов вроде
// «ГорЗайм»). Пусто → фолбэк на chat_model. Пример: AITUNNEL_AGGREGATOR_MODEL=gpt-4o.
'aggregator_model' => env('AITUNNEL_AGGREGATOR_MODEL'),
// ИИ-вызовы (анализ рубрик, эмбеддинги) — с ЗАПАСОМ: латентность модели плавает
// (один и тот же запрос то 8с, то 30-40с), а сбор асинхронный и клиент ждёт — режем
// медленный-но-настоящий ответ таймаутом ХУЖЕ, чем ждём. Потому большие таймауты.
'timeout_sec' => (int) env('AITUNNEL_TIMEOUT_SEC', 120),
// Батч отсева агрегаторов: список фирм режем на порции по aggregator_batch_size (иначе
// один запрос на ~100 фирм долгий и рискует не успеть) + отдельный долгий таймаут на порцию.
'aggregator_batch_size' => (int) env('AITUNNEL_AGGREGATOR_BATCH_SIZE', 40),
'aggregator_timeout_sec' => (int) env('AITUNNEL_AGGREGATOR_TIMEOUT_SEC', 240),
// Канал В — генератор имён (ZAFIKSIROVANO §0-БИС): одна рассуждающая модель sonar-reasoning-pro,
// в реальном прогоне ~30-70 с/вызов и плавает вверх; 2 прохода. Большой таймаут с запасом —
// лучше дождаться, чем прислать пусто (сбор асинхронный, клиент всё равно ждёт результат).
'research_model' => env('AITUNNEL_RESEARCH_MODEL', 'sonar-reasoning-pro'),
'research_timeout_sec' => (int) env('AITUNNEL_RESEARCH_TIMEOUT_SEC', 300),
// Плитка балансов (16.07): ручка баланса `/v1/aitunnel/balance` (найдена в доках
// aitunnel, отдаёт {"balance":N} в рублях) + пороги светофора + ссылка пополнения.
'balance_url' => env('AITUNNEL_BALANCE_URL', 'https://api.aitunnel.ru/v1/aitunnel/balance'),
'red_floor_rub' => (int) env('AITUNNEL_RED_FLOOR_RUB', 500),
'amber_floor_rub' => (int) env('AITUNNEL_AMBER_FLOOR_RUB', 2000),
'topup_url' => env('AITUNNEL_TOPUP_URL', 'https://aitunnel.ru/'),
],
// EXA — поиск САЙТА по имени для канала В (федералы): у федерала нет карточки в 2ГИС/Яндексе
// на регион, а нам нужен сайт (ZAFIKSIROVANO / §11.5). Ключ только в .env. Пусто → канал В без сайтов.
'exa' => [
'key' => env('EXA_API_KEY'),
'base_url' => env('EXA_BASE_URL', 'https://api.exa.ai'),
'timeout_sec' => (int) env('EXA_TIMEOUT_SEC', 90),
// Сколько запросов сайта-федерала слать ОДНОВРЕМЕННО (пул EXA). Лимит EXA — на КЛЮЧ,
// общий на всех клиентов, поэтому бережно; глобальный потолок между клиентами — очередь.
'concurrency' => (int) env('EXA_CONCURRENCY', 5),
// Прокси для EXA (Guzzle proxy option). EXA за Cloudflare режет российские IP, поэтому
// федеральный канал ходит наружу через зарубежный выход. Формат socks5h://host:port или
// http://host:port. Пусто → напрямую.
'proxy' => env('EXA_PROXY'),
// У EXA нет программной ручки баланса (биллинг только в кабинете) → плитка «жив/нет».
'balance_url' => env('EXA_BALANCE_URL'),
'red_floor_rub' => (int) env('EXA_RED_FLOOR_RUB', 500),
'amber_floor_rub' => (int) env('EXA_AMBER_FLOOR_RUB', 2000),
'topup_url' => env('EXA_TOPUP_URL', 'https://dashboard.exa.ai/'),
// Плитка балансов (16.07): нейтральный адрес для проверки живости SSH-тоннеля
// (запрос через exa.proxy на него). Не EXA — чтобы отделить «тоннель жив» от «EXA жив».
'tunnel_check_url' => env('EXA_TUNNEL_CHECK_URL', 'https://api.ipify.org'),
// «Пополнить» для EXA-тоннеля: за ним платный зарубежный VPN (клиент Happ). Провайдер
// подписки задаётся владельцем — если платишь не через Happ, переопредели EXA_TUNNEL_TOPUP_URL.
'tunnel_topup_url' => env('EXA_TUNNEL_TOPUP_URL', 'https://happ-proxy.com/'),
],
// Плитка балансов (28.06): Yandex Cloud биллинг (серверы + Managed PG ~18к/мес).
// OAuth владельца (interim) → IAM-токен → billing API. SA billing-reader создан,
// миграция на него — follow-up (только источник токена сменится). console_billing_url
// + billing_account_id строят прямую ссылку «Пополнить» в дашборде.
'yandex_cloud' => [
'oauth_token' => env('YC_OAUTH_TOKEN'),
'billing_account_id' => env('YC_BILLING_ACCOUNT_ID'),
'iam_url' => env('YC_IAM_URL', 'https://iam.api.cloud.yandex.net/iam/v1/tokens'),
'billing_url' => env('YC_BILLING_URL', 'https://billing.api.cloud.yandex.net/billing/v1/billingAccounts'),
'console_billing_url' => env('YC_CONSOLE_BILLING_URL', 'https://console.yandex.cloud/billing/accounts'),
'daily_spend_rub' => (int) env('YC_DAILY_SPEND_RUB', 600), // оценка ~18к/мес; откалибровать
'red_floor_rub' => (int) env('YC_RED_FLOOR_RUB', 1000),
'amber_floor_rub' => (int) env('YC_AMBER_FLOOR_RUB', 5000),
],
// G7-A: клиентская «Помощь».
'support' => [
'email' => env('SUPPORT_EMAIL', 'support@liderra.ru'),
],
// Яндекс.Метрика. Два счётчика:
// counter_id — кабинет (110494416), историческая аналитика кабинета.
// path_counter_id — «полный путь» (110476275), тот же счётчик, что на лендинге
// liderra.ru. Грузится на входе/регистрации/публичных страницах
// и в кабинете, чтобы Вебвизор видел цепочку от клика по рекламе
// до действий в кабинете одним человеком, а не двумя.
// Пусто = соответствующий счётчик не грузится.
// Админка и портал продаж Метрику не грузят вообще — см. router/index.ts.
'metrika' => [
'counter_id' => env('METRIKA_COUNTER_ID'),
'path_counter_id' => env('METRIKA_PATH_COUNTER_ID'),
],
// ИИ-бот техподдержки в СВОЁМ окошке чата на сайте (спека 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', true),
// Показ примера на ЛЕНДИНГЕ для гостя (не вошедшего клиента): раздел «Как это
// работает» с якорями по темам (задача 2026-07-13). Гость не может открыть
// экскурсию внутри кабинета — ему нечего там показывать, но разбор на лендинге
// доступен без входа. Какие статьи имеют разбор — config/guides.php (связь по слагу).
'landing_url' => env('LANDING_URL', 'https://liderra.ru'),
// Мозг бота: yandex (данные не покидают РФ) | aitunnel (зарубежные модели через
// российский шлюз; ПДн вырезаются PersonalDataMask до отправки).
'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' (один процесс, гонки не бывает).
'limiter_store' => env('BOT_LIMITER_STORE', 'redis'),
],
// YandexGPT Lite (Yandex Cloud Foundation Models) — мозг бота (решение 8 протокола).
'yandexgpt' => [
'api_key' => env('YANDEX_GPT_API_KEY', ''),
'folder_id' => env('YANDEX_GPT_FOLDER_ID', ''),
'model' => env('YANDEX_GPT_MODEL', 'yandexgpt-lite/latest'),
'endpoint' => env('YANDEX_GPT_ENDPOINT', 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion'),
'timeout_seconds' => 8,
],
// Учёт посетителей: офлайн-базы гео (DB-IP Lite). Файлов нет → город не определяется,
// учёт продолжает работать. Обновление баз — раз в месяц, вручную.
'geoip' => [
'city_db' => env('GEOIP_CITY_DB', storage_path('geoip/dbip-city-lite.mmdb')),
'asn_db' => env('GEOIP_ASN_DB', storage_path('geoip/dbip-asn-lite.mmdb')),
],
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
// на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
// 77.75.154.128/25,77.75.156.11,77.75.156.35,2a02:5180::/32.
'yookassa' => [
'shop_id' => env('YOOKASSA_SHOP_ID'),
'secret_key' => env('YOOKASSA_SECRET_KEY'),
'api_url' => env('YOOKASSA_API_URL', 'https://api.yookassa.ru/v3'),
'webhook_ip_allowlist' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('YOOKASSA_WEBHOOK_IPS', '')),
))),
],
// Автоподбор шаг 2: сервис обхода антибота справочников (2ГИС/Яндекс) — xfetch.ru.
// Ключ — ТОЛЬКО в .env (gitignored), в код/гит не попадает. Без ключа загрузчик
// молча возвращает пусто (поток не падает).
'xfetch' => [
'key' => env('XFETCH_API_KEY'),
// Несколько ключей через запятую (round-robin) — суммарная параллельность = ключи × concurrency.
// Пусто → берётся одиночный XFETCH_API_KEY. Ключи — ТОЛЬКО в .env, в код/гит не попадают.
'keys' => array_values(array_filter(
array_map('trim', explode(',', (string) env('XFETCH_API_KEYS', ''))),
static fn (string $v): bool => $v !== '',
)),
'endpoint' => env('XFETCH_ENDPOINT', 'https://xf4.ru/fetch'),
// Параллельность НА КЛЮЧ. Эмпирический предел xf4.ru — ~3 одновременных на ключ
// (выше идут 429 Too Many Requests; замер 01.07.2026). Меняется без деплоя через .env.
'concurrency' => (int) env('XFETCH_CONCURRENCY', 3),
'balance_url' => env('XFETCH_BALANCE_URL'),
'red_floor_rub' => (int) env('XFETCH_RED_FLOOR_RUB', 300),
'amber_floor_rub' => (int) env('XFETCH_AMBER_FLOOR_RUB', 1000),
'topup_url' => env('XFETCH_TOPUP_URL', 'https://xf4.ru/'),
],
// Автоподбор self-render: свой рендер-сервис на виртуалке (замена xf4, за флагом autopodbor.self_render).
// Секрет портал↔виртуалка и адрес — ТОЛЬКО в .env. Без них SelfRenderClient молча пусто (деградация).
'self_render' => [
'endpoint' => env('SELF_RENDER_ENDPOINT', ''),
'key' => env('SELF_RENDER_KEY', ''),
// Параллельность запросов к виртуалке (браузерная параллельность — внутри виртуалки, §12).
'concurrency' => (int) env('SELF_RENDER_CONCURRENCY', 8),
// Плитка балансов (16.07): URL health-check виртуалки. Пусто → пингуем endpoint.
'health_url' => env('SELF_RENDER_HEALTH_URL'),
],
// Плитка балансов (17.07): прокси автоподбора (Proxy.Market, мобильный Tele2). «Баланс» = срок
// действия. Живость проверяет виртуалка-рендер (у неё логин/пароль прокси) через /proxy-check.
// Ключ виртуалки берём из services.self_render.key. Срок — руками в .env при продлении.
'proxy_market' => [
'check_url' => env('PROXY_MARKET_CHECK_URL'), // https://51-250-1-97.sslip.io/proxy-check
'expires_at' => env('PROXY_MARKET_EXPIRES_AT'), // 2026-09-03 (продлён 05.08.2026)
'amber_days' => (int) env('PROXY_MARKET_AMBER_DAYS', 7),
'topup_url' => env('PROXY_MARKET_TOPUP_URL', 'https://proxy.market/'),
],
// Куда слать алерты о недоступности/исходе денег внешних сервисов.
'monitoring' => [
'alert_email' => env('MONITORING_ALERT_EMAIL', env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru')),
],
// Баланс Яндекс 360 (почта) — РУЧНОЙ ввод (см. Yandex360BalanceProvider). Пороги
// светофора — как у dadata; topup_url — страница «Оплата и тариф» в кабинете Яндекс 360.
'yandex360' => [
'red_floor_rub' => (int) env('YANDEX360_RED_FLOOR_RUB', 500),
'amber_floor_rub' => (int) env('YANDEX360_AMBER_FLOOR_RUB', 2000),
'topup_url' => env('YANDEX360_TOPUP_URL', 'https://admin.yandex.ru/products'),
],
// Плитка балансов (16.07): служба поиска клиентов отдела продаж (/opt/sales-finder, :8200
// за nginx /finder/). Только живость (2ГИС-пробив; денежного баланса нет). Пусто → не проверяем.
// Дефолт ПУСТОЙ намеренно: реальный URL задаётся только в прод-.env (SALES_FINDER_BASE_URL),
// иначе dev/тесты стучались бы в боевой /finder/. Прод-адрес — https://lk.liderra.ru/finder/.
'sales_finder' => [
'base_url' => env('SALES_FINDER_BASE_URL', ''),
'health_path' => env('SALES_FINDER_HEALTH_PATH', ''),
],
// Плитка балансов (16.07): Keyso (keys.so) — разведка рекламы, зависимость службы
// «Поиск клиентов» (/opt/sales-finder держит токен). Портал только проверяет живость
// хоста (пинг api.keys.so, <500 = жив), денег/токена не нужно.
'keyso' => [
'base_url' => env('KEYSO_BASE_URL', 'https://api.keys.so'),
'topup_url' => env('KEYSO_TOPUP_URL', 'https://www.keys.so/ru/tarif'),
],
'sms' => [
// Песочница: провайдеры не вызываются, отправка только помечается в журнале.
'sandbox' => (bool) env('SMS_SANDBOX', true),
// Наша организация в бланке согласия клиента (кому клиент даёт согласие
// зарегистрировать имя). Согласуется с владельцем — юридический текст.
'consent_our_entity' => env('SMS_CONSENT_OUR_ENTITY', 'сервис «Лидерра» (liderra.ru)'),
// Резерв канала: точный оператор отказал → попробовать универсала.
// По умолчанию ВЫКЛ (решение владельца 23.07.2026) — включить одной строкой.
'failover_to_universal' => filter_var(env('SMS_FAILOVER_TO_UNIVERSAL', false), FILTER_VALIDATE_BOOL),
// Канал МТС = «МТС Маркетолог, Рассылки по своей базе PRO» (омни-адаптер
// api.mts.ru). naming — согласованное имя отправителя (MTSM_Test/liderra.ru);
// connect_to — мост (host:port SSH-туннеля, напр. 127.0.0.1:18443), пусто → напрямую.
'mts' => [
'token' => env('SMS_MTS_TOKEN'),
'enabled' => (bool) env('SMS_MTS_ENABLED', false),
'base_url' => env('SMS_MTS_BASE_URL', 'https://api.mts.ru/client-omni-adapter_production/1.0.2'),
'naming' => env('SMS_MTS_NAMING', ''),
'connect_to' => env('SMS_MTS_CONNECT_TO', ''),
// Обратный сигнал о судьбе (строка листа 5.2): МТС сам шлёт отчёт на
// /api/webhook/mts-delivery/{secret}. Секрет короче 32 знаков или пустой =
// приёмник ЗАКРЫТ (судьбу и без него доберёт опрос каждые 10 минут).
// Список адресов отправителя — через запятую, можно подсетями; пуст =
// не проверяем (адреса МТС пока неизвестны, В-243).
'webhook_secret' => env('SMS_MTS_WEBHOOK_SECRET', ''),
'webhook_ips' => env('SMS_MTS_WEBHOOK_IPS', ''),
],
'beeline' => [
'token' => env('SMS_BEELINE_TOKEN'),
'enabled' => (bool) env('SMS_BEELINE_ENABLED', false),
],
// Канал Теле2 = «SMS-Таргет» (target.t2.ru), HTTP API v2, вход по логину и
// паролю (Basic Auth) из раздела кабинета «Профиль → API-рассылки».
// naming — согласованное имя отправителя (Liderra).
// 🪤 t2 пускает к API только с ЗАЯВЛЕННЫХ адресов: адрес боевого сервера
// надо назвать в настройках подключения, иначе 403 wrong_ip.
'tele2' => [
'login' => env('SMS_T2_LOGIN'),
'password' => env('SMS_T2_PASSWORD'),
'enabled' => (bool) env('SMS_T2_ENABLED', false),
'base_url' => env('SMS_T2_BASE_URL', 'https://target.t2.ru/api/v2'),
'naming' => env('SMS_T2_NAMING', ''),
// Обратный звонок о судьбе (строка листа 5.2): t2 сам зовёт
// /api/webhook/t2-delivery/{secret}?id=&status=&parts=. Секрет короче
// 32 знаков или пустой = приёмник ЗАКРЫТ. 🪤 t2 ответа не проверяет и
// повторов НЕ делает — пропущенный звонок доберёт только опрос.
// Адреса отправителя — через запятую; пуст = не проверяем (адреса t2
// нам неизвестны, выдумывать нельзя).
'webhook_secret' => env('SMS_T2_WEBHOOK_SECRET', ''),
'webhook_ips' => env('SMS_T2_WEBHOOK_IPS', ''),
],
// Декларативный реестр живых каналов. Новый оператор = запись здесь
// + ключ в .env + (если API другой) файл-провайдер + арм в makeSmsProvider().
// Цены — в копейках за сегмент, ключ '*' = единая цена канала; меняются
// через .env без выката.
'providers' => [
'smsc' => [
'enabled' => (bool) env('SMS_SMSC_ENABLED', false),
'serves' => ['*'],
'price_kopecks' => ['*' => (int) env('SMS_SMSC_PRICE_KOP', 0)],
],
'mts' => [
'enabled' => (bool) env('SMS_MTS_ENABLED', false),
'serves' => ['mts'],
'price_kopecks' => ['*' => (int) env('SMS_MTS_PRICE_KOP', 0)],
],
'tele2' => [
'enabled' => (bool) env('SMS_T2_ENABLED', false),
'serves' => ['tele2'],
'price_kopecks' => ['*' => (int) env('SMS_T2_PRICE_KOP', 0)],
],
],
],
// ─────────────────────────────────────────────────────────────────────────
// Обзвон: шов «портал зовёт голосового робота» (З-0.3, решение владельца Р84).
//
// 🔴 Робот живёт на ОТДЕЛЬНОЙ машине и делит железо с боевым рендером «Поиска
// клиентов» — это живые деньги живых клиентов. Поэтому предел одновременных
// заданий и пределы времени стоят здесь С САМОГО НАЧАЛА, а не «когда-нибудь
// потом»: голос не имеет права уронить чужой боевой рендер.
//
// Адрес и тайный ключ — ТОЛЬКО в .env. Пусто = дверь закрыта: {@see ObzvonClient}
// наружу не ходит, звонка точно не было, и он это говорит вслух (мягкая
// деградация — та же, что у self_render без ключа).
// ─────────────────────────────────────────────────────────────────────────
'obzvon' => [
// БАЗОВЫЙ адрес приёмника заданий на машине робота (без хвоста ручки):
// клиент сам добавит /pozvonit, /sostoyanie, /ostanovit. Сам приёмник —
// задача З-0.6, его ещё нет; портальная половина от этого не зависит.
//
// 🔴 Р123: адрес обязан быть `https://`. Тайный ключ едет ЗАГОЛОВКОМ в
// каждом запросе — по `http://` он поехал бы открытым текстом и молча.
// Адрес без шифрования портал не принимает: дверь закрыта, звонка нет.
// Исключение ровно одно — разработчик на своём компьютере, и оно НЕ
// настраивается отсюда: тумблера «можно без шифра» тут нет намеренно,
// такой тумблер и был бы той самой опечаткой. Как устроено исключение —
// см. `ObzvonClient::svoyaMashina()`.
'endpoint' => env('OBZVON_ENDPOINT', ''),
'key' => env('OBZVON_KEY', ''),
// Сколько заданий разрешено держать у робота ОДНОВРЕМЕННО.
// 🔴 Предел общий на машину, а не «по числу в памяти работника очереди»:
// он взят именованными замками кэша (см. ObzvonClient), поэтому два и
// более работников очереди в РАЗНЫХ процессах делят одни и те же места.
// Чего он НЕ ограничивает — см. шапку ObzvonClient, там сказано прямо.
'concurrency' => (int) env('OBZVON_CONCURRENCY', 4),
// Сколько СЕКУНД лишнее задание согласно ждать свободного места. Дождалось —
// пошло; не дождалось — исход «робот занят»: наружу не ходили, звонка точно
// не было, повторить позже безопасно. 0 = не ждать вовсе (так гоняем тесты).
'wait_seconds' => (int) env('OBZVON_WAIT_SECONDS', 10),
// Два предела времени, а не один — и это не перестраховка.
// connect_timeout — дозвониться до машины робота;
// timeout — ВЕСЬ обмен целиком, вместе с чтением ответа.
// Второй нужен ровно против случая «робот отдаёт ответ по байту бесконечно»:
// соединение установлено, дальше чтение висит вечно и держит работника
// очереди. Один только connect_timeout такой случай НЕ ловит.
'connect_timeout' => (int) env('OBZVON_CONNECT_TIMEOUT', 5),
'timeout' => (int) env('OBZVON_TIMEOUT', 15),
// 🔴 Куда клиенту ВООБЩЕ разрешено ходить с тайным ключом. Опечатка в
// OBZVON_ENDPOINT уводит запрос в чужой интернет — и тайный ключ уезжает
// чужому человеку, молча и навсегда. Хост не из этого списка = дверь
// закрыта, запрос не отправлен, ключ никуда не ушёл.
// Пустой список = закрыто ВСЁ (fail-closed, а не «раз пусто — значит можно»).
// Переезд робота на другой адрес — осознанная правка .env в двух местах.
'allowed_hosts' => array_values(array_filter(
array_map('trim', explode(',', (string) env(
'OBZVON_ALLOWED_HOSTS',
'51.250.1.97,51-250-1-97.sslip.io,127.0.0.1,localhost',
))),
static fn (string $v): bool => $v !== '',
)),
],
];