diff --git a/app/.env.example b/app/.env.example index d994b5f9..3e82ef22 100644 --- a/app/.env.example +++ b/app/.env.example @@ -121,6 +121,20 @@ SMS_SMSC_PRICE_KOP=0 SMS_MTS_ENABLED=false SMS_MTS_TOKEN= SMS_MTS_PRICE_KOP=0 +# Канал Теле2 («SMS-Таргет», target.t2.ru). Логин и пароль берутся в кабинете: +# Профиль → API-рассылки → Настроить подключение. Там же заявляется IP боевого +# сервера — без этого t2 отвечает 403 wrong_ip. Имя отправителя — согласованное. +SMS_T2_ENABLED=false +SMS_T2_LOGIN= +SMS_T2_PASSWORD= +SMS_T2_NAMING= +SMS_T2_PRICE_KOP=0 +# Обратный звонок t2 о судьбе: /api/webhook/t2-delivery/{секрет}?id=&status=&parts= +# Секрет — не короче 32 знаков; пустой = приёмник ЗАКРЫТ (судьбу доберёт опрос). +# 🪤 t2 ответа не проверяет и повторов не делает: пропущенный звонок потерян. +# Адреса отправителя — через запятую, можно подсетями; пусто = не проверяем. +SMS_T2_WEBHOOK_SECRET= +SMS_T2_WEBHOOK_IPS= # Реклама в Телеграме по своей базе (клиентский модуль, робот-в-браузере — у МТС нет API). # Песочница по умолчанию ВКЛ — робот доводит только до черновика, деньги не списываются. diff --git a/app/app/Http/Controllers/Api/T2DeliveryWebhookController.php b/app/app/Http/Controllers/Api/T2DeliveryWebhookController.php new file mode 100644 index 00000000..fe50496b --- /dev/null +++ b/app/app/Http/Controllers/Api/T2DeliveryWebhookController.php @@ -0,0 +1,205 @@ +&status=&parts= + * + * 🔴 **Форма у t2 другая, чем у МТС**: не письмо с телом, а строки прямо в адресе. + * Отсюда `match(['get','post'])` — документация показывает адрес со строками + * запроса, а какой глагол t2 при этом шлёт, живьём никто не проверял. Принимаем оба + * и читаем строки одинаково: догадка тут стоила бы потерянных отчётов. + * + * 🔴 **Это НАДСТРОЙКА, а не замена опроса** `client-sms:poll-delivery`. Более того, + * у t2 опрос нужнее, чем у МТС: **t2 не проверяет наш ответ и повторов НЕ делает** + * (журнал t2 HTTP API v2) — пропущенный звонок потерян навсегда. Поэтому любой + * отказ приёмника безобиден: всё доберёт ближайший заход опроса, и везде здесь + * выбран ноль вместо догадки. + * + * 🔴 **Содержимое звонка в журнал сервера НЕ пишем**: телефона в нём нет, но + * привычка та же, что у МТС — только перечень имён полей. + * + * 🔴 **Деньги отсюда не двигаются вовсе.** Возврат за недоставленное живёт в команде + * опроса, у него своя двойная защита от повтора (решение владельца В-198). + * + * Защита — та же, что у приёмника МТС (публичный адрес в интернете): + * 1. `{secret}` в адресе, сравнение `hash_equals`, короче 32 знаков не принимаем. + * Секрет не задан = приёмник закрыт наглухо, а не «пускать всех». + * 2. Список разрешённых адресов отправителя (`SMS_T2_WEBHOOK_IPS`, через запятую, + * можно подсетями). 🪤 Пока ПУСТ: адреса t2 нам неизвестны, выдумывать нельзя. + * Пустой список = не проверяем, вся защита держится на секрете. + * 3. Счётчик обращений с адреса — от потока даже с верным секретом. + * + * Несовпадение секрета или адреса → **404**: существование приёмника чужому не + * подтверждаем. + */ +class T2DeliveryWebhookController extends Controller +{ + /** Ключ канала, чьи отчёты принимает этот адрес. */ + private const PROVIDER_KEY = 'tele2'; + + /** Обращений в минуту с одного адреса. Скорость потока t2 нам неизвестна — + * берём ту же меру, что у МТС: она с запасом покрывает наши объёмы. */ + private const RATE_LIMIT_PER_MINUTE = 600; + + public function receive( + Request $request, + string $secret, + SmsRouter $router, + ClientSmsDeliveryWriter $writer, + ): Response|JsonResponse { + if (! $this->verifySecret($secret)) { + return $this->notFound(); + } + + if (! $this->verifyIpAllowlist($request->ip())) { + Log::warning('client_sms.delivery_hook_foreign_ip', [ + 'provider' => self::PROVIDER_KEY, + 'ip' => $request->ip(), + ]); + + return $this->notFound(); + } + + $this->noteOpenGate($request->ip()); + + $rateKey = 't2-delivery-hook:'.($request->ip() ?? 'unknown'); + if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) { + $retryAfter = RateLimiter::availableIn($rateKey); + + return response()->json(['message' => 'Превышен лимит запросов.'], 429) + ->header('Retry-After', (string) $retryAfter); + } + RateLimiter::hit($rateKey, 60); + + $parser = $this->parser($router); + + if ($parser === null) { + // Канал не поднят (выключен или песочница) — разбирать звонок нечем. + // Молчать нельзя (урок В-121), но и отказывать t2 незачем. + Log::warning('client_sms.delivery_hook_no_channel', ['provider' => self::PROVIDER_KEY]); + + return $this->accepted(); + } + + // Строки запроса и тело разом: глагол звонка живьём не проверен (см. шапку). + /** @var array $params */ + $params = $request->all(); + + $reports = $parser->parseDeliveryPayload($params); + + if ($reports === []) { + // Звонок не узнан. Пишем его ФОРМУ — перечень имён полей, без значений. + Log::warning('client_sms.delivery_hook_unparsed', [ + 'provider' => self::PROVIDER_KEY, + 'keys' => array_keys($params), + 'size' => count($params), + ]); + + return $this->accepted(); + } + + $updated = $writer->applyMany(self::PROVIDER_KEY, $reports); + + // «Успех с нулём» — не повод молчать (урок В-121): пришёл отчёт про + // сообщение, которого мы не знаем. + if ($updated === 0) { + Log::warning('client_sms.delivery_hook_unknown_messages', [ + 'provider' => self::PROVIDER_KEY, + 'reports' => count($reports), + ]); + } + + return $this->accepted(); + } + + /** + * Ответ t2 не проверяет вовсе — отдаём самое дешёвое: 204, пустое тело. + * Тот же ответ, что у приёмника МТС: два приёмника, одна привычка. + */ + private function accepted(): Response + { + return response()->noContent(); + } + + private function notFound(): JsonResponse + { + return response()->json(['message' => 'Not found.'], 404); + } + + /** Канал, умеющий разобрать звонок о судьбе. Нет такого — null. */ + private function parser(SmsRouter $router): ?SmsDeliveryPayloadParser + { + foreach ($router->providers() as $provider) { + if ($provider instanceof SmsDeliveryPayloadParser && $provider->key() === self::PROVIDER_KEY) { + return $provider; + } + } + + return null; + } + + private function verifySecret(string $provided): bool + { + $expected = (string) config('services.sms.tele2.webhook_secret', ''); + + // Не задан или слишком короток — приёмник считается ненастроенным и не + // работает вовсе. Это НЕ «пускать всех»: адрес публичный, а за ним деньги. + if (strlen($expected) < 32) { + return false; + } + + return hash_equals($expected, $provided); + } + + /** + * Пока белый список пуст, отказа никому нет — а значит, и записи, с какого + * адреса звонит t2, взяться неоткуда: заполнить список было бы нечем. + * Разрываем круг — пишем адрес звонившего. Как только список задан, смолкает. + */ + private function noteOpenGate(?string $clientIp): void + { + if (trim((string) config('services.sms.tele2.webhook_ips', '')) !== '') { + return; + } + + Log::notice('client_sms.delivery_hook_open_gate', [ + 'provider' => self::PROVIDER_KEY, + 'ip' => $clientIp, + ]); + } + + private function verifyIpAllowlist(?string $clientIp): bool + { + $raw = trim((string) config('services.sms.tele2.webhook_ips', '')); + + // Список пуст — адреса t2 нам ещё неизвестны, проверять нечем. Защита + // держится на секрете; список включается одной строкой в `.env`. + if ($raw === '') { + return true; + } + + if ($clientIp === null) { + return false; + } + + $list = array_values(array_filter(array_map(trim(...), explode(',', $raw)))); + + return $list === [] || IpUtils::checkIp($clientIp, $list); + } +} diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index 35288d45..79442d2c 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -23,6 +23,7 @@ use App\Services\DaData\PartyLookup; use App\Services\Sms\Providers\FakeSmsProvider; use App\Services\Sms\Providers\MtsSmsProvider; use App\Services\Sms\Providers\SmscSmsProvider; +use App\Services\Sms\Providers\T2SmsProvider; use App\Services\Sms\SmsProvider; use App\Services\Sms\SmsRouter; use App\Services\Supplier\Channel\AjaxProjectChannel; @@ -162,6 +163,16 @@ class AppServiceProvider extends ServiceProvider ($ct = (string) config('services.sms.mts.connect_to')) !== '' ? $ct : null, ) : null, + 'tele2' => ($login = (string) config('services.sms.tele2.login')) !== '' + ? new T2SmsProvider( + $login, + (string) config('services.sms.tele2.password'), + $serves, + $price, + (string) config('services.sms.tele2.base_url'), + (string) config('services.sms.tele2.naming'), + ) + : null, default => null, }; } diff --git a/app/app/Services/Sms/Providers/T2SmsProvider.php b/app/app/Services/Sms/Providers/T2SmsProvider.php new file mode 100644 index 00000000..73a371f1 --- /dev/null +++ b/app/app/Services/Sms/Providers/T2SmsProvider.php @@ -0,0 +1,357 @@ +","text":"…"}. + * Ответ: {"result":{"uid":"message-id-…"},"status":"ok"}. + * + * @param array $serves + * @param array $priceKopecks + */ +final class T2SmsProvider implements SmsDeliveryPayloadParser, SmsDeliveryReporter, SmsProvider +{ + public function __construct( + private readonly string $login, + private readonly string $password, + private readonly array $serves, + private readonly array $priceKopecks, + private readonly string $baseUrl = 'https://target.t2.ru/api/v2', + private readonly string $naming = '', + ) {} + + public function key(): string + { + return 'tele2'; + } + + public function servesOperators(): array + { + return $this->serves; + } + + public function priceKopecks(string $operator): int + { + return $this->priceKopecks[$operator] ?? $this->priceKopecks['*'] ?? 0; + } + + public function send(SmsOutgoing $message): SmsSendResult + { + $shortcode = $this->shortcodeFor($message->senderName); + + try { + $response = Http::withBasicAuth($this->login, $this->password) + ->post(rtrim($this->baseUrl, '/').'/send_message', [ + // Формат t2 — 7nnnNNNnnNN числом, а не строкой. + 'msisdn' => (int) $message->phone, + 'shortcode' => $shortcode, + 'text' => $message->body, + ]); + } catch (Throwable $e) { + throw new SmsSendException('Теле2 недоступен: '.$e->getMessage(), terminal: false); + } + + // 5xx — сбой на стороне t2, повтор осмыслен; иной не-2xx (401 auth_required, + // 403 wrong_ip/wrong_credentials) — наша настройка, повтор не поможет. + if ($response->serverError()) { + throw new SmsSendException('Теле2 вернул '.$response->status(), terminal: false); + } + if (! $response->successful()) { + throw new SmsSendException('Теле2 вернул '.$response->status(), terminal: true); + } + + $body = (array) $response->json(); + + // 🪤 t2 отвечает 200 даже на отказ — судить надо по полю status, а не по + // коду HTTP. Пропустить это значило бы записать отказ как отправленное. + if (($body['status'] ?? null) !== 'ok') { + $reason = (string) ($body['reason'] ?? 'причина не названа'); + + throw new SmsSendException( + 'Теле2 отказал: '.$reason, + // system_error — «не смогли сохранить сообщение» на их стороне, + // через минуту получится. Остальное (кривой номер, чужое имя + // отправителя) — наше, повторять бесполезно. + terminal: $reason !== 'system_error', + ); + } + + return new SmsSendResult( + providerMessageId: (string) ($body['result']['uid'] ?? ''), + segments: $message->segments, + costKopecks: $this->priceKopecks($message->operator) * $message->segments, + acceptedAt: CarbonImmutable::now(), + ); + } + + /** + * Имя отправителя для t2. + * + * 🔴 Имена регистрируются У КАЖДОГО ОПЕРАТОРА ОТДЕЛЬНО, и написание бывает + * разным: у МТС согласовано `liderra.ru`, у t2 — `Liderra.ru`. А клиентская + * рассылка несёт ОДНО имя на всех (`campaign.sender_name`), потому что имя + * там выбирается на кампанию, а не на канал (в модуле отдела продаж это уже + * сделано по каналу — `SalesSmsSender::activeByProviderKey`). + * + * Проверено живьём на бою 04.08.2026: `liderra.ru` → отказ + * `invalid_source_address`, `Liderra.ru` → принято. + * + * Поэтому приводим написание к согласованному с t2 — но ТОЛЬКО когда это то + * же самое имя, отличающееся регистром. Чужое имя не трогаем: подменить его + * значило бы соврать про отправителя, а честный отказ t2 лучше тихой подмены. + * + * 🪤 Это лечение половинчатое. Правильное — реестр имён по каналам и в + * клиентском модуле; тогда эта склейка станет не нужна. + */ + private function shortcodeFor(string $requested): string + { + if ($requested === '') { + return $this->naming; + } + + if ($this->naming !== '' && mb_strtolower($requested) === mb_strtolower($this->naming)) { + return $this->naming; + } + + return $requested; + } + + /** + * Пакетного запроса у t2 НЕТ — спрашивать можно только по одному сообщению. + * Единица здесь не осторожность, а форма их API: адрес опроса несёт номер + * сообщения прямо в пути. + */ + public function deliveryBatchSize(): int + { + return 1; + } + + /** + * Судьба сообщений (строка листа 5.1). У t2 один адрес на одно сообщение: + * GET .../send_message/message-id-{id} → {"result":{…},"status":"ok"}. + * + * 🪤 Номер сообщения, который вернула отправка, УЖЕ несёт приставку + * `message-id-` — второй раз её клеить нельзя. + * + * @param array $providerMessageIds + * @return array + */ + public function fetchDelivery(array $providerMessageIds): array + { + $reports = []; + + foreach ($providerMessageIds as $id) { + if ((string) $id === '') { + continue; + } + + foreach ($this->fetchOne((string) $id) as $report) { + $reports[] = $report; + } + } + + return $reports; + } + + /** + * Один опрос. + * + * Любой отказ здесь НЕ окончательный, в отличие от отправки: судьбу всегда + * можно переспросить следующим заходом, и терять её из-за минутного сбоя незачем. + * + * @return array + */ + private function fetchOne(string $providerMessageId): array + { + try { + $response = Http::withBasicAuth($this->login, $this->password) + ->get(rtrim($this->baseUrl, '/').'/send_message/'.rawurlencode($providerMessageId)); + } catch (Throwable $e) { + throw new SmsSendException('Теле2 недоступен: '.$e->getMessage(), terminal: false); + } + + if (! $response->successful()) { + throw new SmsSendException('Теле2 вернул '.$response->status(), terminal: false); + } + + $body = (array) $response->json(); + + // 🪤 И при опросе отказ приезжает с кодом 200 — судить по полю status. + if (($body['status'] ?? null) !== 'ok') { + throw new SmsSendException( + 'Теле2 отказал: '.(string) ($body['reason'] ?? 'причина не названа'), + terminal: false, + ); + } + + return $this->parseDeliveryPayload($body); + } + + /** + * Разбор того, что рассказал t2 про судьбу. Один дом формы на ДВА входа — + * наш опрос и обратный звонок, который t2 делает сам (строка листа 5.2). + * + * Формы две, и они РАЗНЫЕ: + * • ответ на опрос — `{"result":{"uid","status","msisdn","pdu_count", + * "created_at","updated_at",…},"status":"ok"}`; + * • обратный звонок — строки в адресе: `?id=&status=&parts=` + * (плюс `err` при `rejected`; `parts` не приходит при `rejected` и + * `insufficient_balance`). + * + * 🪤 Слово `status` в ответе на опрос встречается ДВАЖДЫ: снаружи это «запрос + * удался», внутри `result` — судьба сообщения. Спутать их значит объявить + * доставленным всё подряд, поэтому наружное сюда не заглядывает вовсе. + * + * 🔴 Не узнали бумагу — ПУСТОЙ список. Ноль безопасен: пропущенное доберёт + * опрос, а выдуманное двигало бы ДЕНЬГИ (за «не доставлено» клиенту + * возвращаются рубли, В-198). + * + * @param array $body + * @return array + */ + public function parseDeliveryPayload(array $body): array + { + // Ответ на опрос: начинка в `result`; она же без конверта — если есть `uid`. + $result = $body['result'] ?? null; + + if (is_array($result) && isset($result['uid'])) { + return $this->reportsFromPoll($result); + } + + if (isset($body['uid'])) { + return $this->reportsFromPoll($body); + } + + // Обратный звонок: номер сообщения приезжает под именем `id`. + if (isset($body['id'])) { + return $this->reportsFromCallback($body); + } + + return []; + } + + /** + * @param array $result + * @return array + */ + private function reportsFromPoll(array $result): array + { + $providerMessageId = (string) ($result['uid'] ?? ''); + + if ($providerMessageId === '') { + return []; + } + + $raw = (string) ($result['status'] ?? ''); + $state = $this->mapDeliveryState($raw); + + // Отдельного времени доставки t2 не присылает. Момент последней перемены + // (`updated_at`) — это и есть миг, когда сообщение стало доставленным; + // у прочих судеб временем доставки он не является, и мы его не ставим. + $happenedAt = $this->parseMoment($result['updated_at'] ?? $result['created_at'] ?? null); + + return [new SmsDeliveryReport( + providerMessageId: $providerMessageId, + phone: isset($result['msisdn']) ? (string) $result['msisdn'] : '', + state: $state, + happenedAt: $happenedAt, + deliveredAt: $state === SmsDeliveryState::DELIVERED ? $happenedAt : null, + // pdu_count у t2 необязательное и часто приходит null. Отдельная проверка + // на null не нужна: isset() на null сама даёт false. + partCount: isset($result['pdu_count']) ? (int) $result['pdu_count'] : null, + // Цены в ответе t2 нет вовсе — выдумывать нечего. + providerCost: null, + rawState: $raw, + )]; + } + + /** + * @param array $params + * @return array + */ + private function reportsFromCallback(array $params): array + { + $providerMessageId = (string) ($params['id'] ?? ''); + + if ($providerMessageId === '') { + return []; + } + + $raw = (string) ($params['status'] ?? ''); + $state = $this->mapDeliveryState($raw); + + // Времени в звонке нет. Ставим миг, когда он до нас дошёл: это и есть + // момент перемены — t2 звонит на КАЖДУЮ смену состояния. + $happenedAt = CarbonImmutable::now(); + + return [new SmsDeliveryReport( + providerMessageId: $providerMessageId, + // Номера в звонке нет; для поиска строки журнала он и не нужен — + // ищем по номеру сообщения. Выдумывать телефон нельзя, это ПДн. + phone: '', + state: $state, + happenedAt: $happenedAt, + deliveredAt: $state === SmsDeliveryState::DELIVERED ? $happenedAt : null, + // `parts` не приходит при rejected и insufficient_balance. + partCount: isset($params['parts']) && $params['parts'] !== '' + ? (int) $params['parts'] + : null, + providerCost: null, + rawState: $raw, + )]; + } + + /** + * Слово t2 → судьба портала. Незнакомое НЕ угадываем: возвращаем null, + * вызывающий оставит графу как была и напишет слово в журнал сервера. + * + * 🔴 Угадать значило бы соврать про ДЕНЬГИ: за «не доставлено» клиенту + * возвращаются рубли (решение владельца В-198). + */ + private function mapDeliveryState(string $raw): ?string + { + return match (mb_strtolower($raw)) { + 'delivered' => SmsDeliveryState::DELIVERED, + 'awaiting_report' => SmsDeliveryState::SENDING, + 'failed' => SmsDeliveryState::NOT_DELIVERED, + // t2 ПРИНЯЛ сообщение, но так и не отправил — это не «не дошло». + // Разговор с оператором по такому другой, и платить за это клиент + // не должен (В-212). + 'insufficient_balance', 'rejected' => SmsDeliveryState::NOT_SENT, + default => null, + }; + } + + /** + * Время из ответа t2 — UNIX-число, а не строка даты. Не разобралось — null, + * а не «сейчас»: выдуманное время хуже пустого, по нему потом считают сроки. + */ + private function parseMoment(mixed $value): ?CarbonImmutable + { + // ctype_digit на пустой строке сама даёт false — отдельная проверка не нужна. + if (is_int($value) || (is_string($value) && ctype_digit($value))) { + $seconds = (int) $value; + + return $seconds > 0 ? CarbonImmutable::createFromTimestamp($seconds) : null; + } + + return null; + } +} diff --git a/app/config/services.php b/app/config/services.php index c25f5c02..42b374dc 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -392,6 +392,26 @@ return [ '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(). // Цены — в копейках за сегмент, ключ '*' = единая цена канала; меняются @@ -407,6 +427,11 @@ return [ '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)], + ], ], ], diff --git a/app/routes/web.php b/app/routes/web.php index 46ab56df..35c42cf6 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -701,6 +701,15 @@ Route::get('/api/chat/{chat}/messages', 'App\Http\Controllers\Api\ChatController Route::post('/api/webhook/mts-delivery/{secret}', 'App\Http\Controllers\Api\MtsDeliveryWebhookController@receive') ->where('secret', '[A-Za-z0-9_\-]+'); +// Обратный звонок Теле2 о судьбе СМС (Этап 5, строка листа 5.2). Форма ДРУГАЯ, +// чем у МТС: строки прямо в адресе (?id=&status=&parts=), а не письмо с телом. +// Глагол живьём не проверен — принимаем и GET, и POST. Защита та же: секрет в +// адресе (≥32 знаков) + необязательный список адресов отправителя. +// 🪤 t2 ответа НЕ проверяет и повторов НЕ делает — пропущенный звонок потерян, +// поэтому опрос `client-sms:poll-delivery` здесь тем более обязателен. +Route::match(['get', 'post'], '/api/webhook/t2-delivery/{secret}', 'App\Http\Controllers\Api\T2DeliveryWebhookController@receive') + ->where('secret', '[A-Za-z0-9_\-]+'); + // Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt. // Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7. Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive'); diff --git a/app/tests/Feature/ClientSms/T2DeliveryWebhookTest.php b/app/tests/Feature/ClientSms/T2DeliveryWebhookTest.php new file mode 100644 index 00000000..780ad77f --- /dev/null +++ b/app/tests/Feature/ClientSms/T2DeliveryWebhookTest.php @@ -0,0 +1,266 @@ +&status=&parts=` (плюс `err` при `rejected`). + * + * 🔴 t2 ответа не проверяет и повторов НЕ делает: пропущенный звонок потерян + * навсегда. Поэтому опрос `client-sms:poll-delivery` остаётся и здесь — приёмник + * только ускоряет, а не заменяет. + * + * 🪤 SharesSupplierPdo обязателен: приёмник кросс-клиентский и правит журнал + * СЛУЖЕБНЫМ соединением. Без общего PDO оно не видит незакоммиченных данных теста + * и находит НОЛЬ — прогон позеленел бы ВРУЩИ (урок В-95). + * + * Помощники с префиксом t2Hook* — имена функций в Pest ГЛОБАЛЬНЫЕ. + * Телефоны только синтетические 7999… — реальные НИКОГДА. + */ +uses(RefreshDatabase::class, SharesSupplierPdo::class); + +/** Секрет приёмника: поедет в адресе, короче 32 знаков не принимаем. */ +const T2_HOOK_SECRET = 't2-delivery-secret-0123456789abcdef'; + +beforeEach(function () { + Carbon::setTestNow('2026-08-15 12:00:00'); + + config(['services.sms.tele2.webhook_secret' => T2_HOOK_SECRET]); + config(['services.sms.tele2.webhook_ips' => '']); + + // Разбор живёт в КАНАЛЕ. В песочнице маршрутизатор держит только заглушку, + // поэтому канал Теле2 ставим руками — в сеть он здесь не ходит. + app()->instance(SmsRouter::class, new SmsRouter([ + new T2SmsProvider('login-not-used-here', 'pass', ['tele2'], ['*' => 692]), + ])); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +/** + * @param array $params + */ +function t2HookUrl(array $params = [], string $secret = T2_HOOK_SECRET): string +{ + $query = $params === [] ? '' : '?'.http_build_query($params); + + return '/api/webhook/t2-delivery/'.$secret.$query; +} + +function t2HookMessage(string $providerMessageId = 'message-id-VvkmLHkfhCX9', string $phone = '79990000001'): ClientSmsMessage +{ + $tenant = Tenant::factory()->create(); + + $campaign = ClientSmsCampaign::create([ + 'tenant_id' => $tenant->id, + 'title' => 'Отчёт от Теле2', + 'body' => 'Текст', + 'sender_name' => 'Liderra', + 'source' => ClientSmsCampaign::SOURCE_MANUAL, + 'status' => ClientSmsCampaign::STATUS_DONE, + 'idempotency_key' => 't2-hook-'.uniqid(), + 'segments' => 1, + 'planned_count' => 1, + 'sent_count' => 1, + 'total_sms' => 1, + 'price_rub_per_sms' => '6.92', + 'estimated_cost_rub' => '6.92', + ]); + + return ClientSmsMessage::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'phone' => $phone, + 'operator' => 'tele2', + 'provider_key' => 'tele2', + 'status' => ClientSmsMessage::STATUS_SENT, + 'provider_message_id' => $providerMessageId, + 'cost_rub' => '6.92', + 'segments' => 1, + ]); +} + +it('принимает звонок t2, правит строку журнала и отвечает без содержимого', function () { + $message = t2HookMessage(); + + $response = $this->get(t2HookUrl([ + 'id' => 'message-id-VvkmLHkfhCX9', + 'status' => 'delivered', + 'parts' => '2', + ])); + + $response->assertNoContent(); + + $message->refresh(); + expect($message->delivery_status)->toBe(SmsDeliveryState::DELIVERED) + ->and($message->delivery_raw)->toBe('delivered') + ->and($message->provider_parts)->toBe(2) + ->and($message->delivered_at)->not->toBeNull() + ->and($message->delivery_checked_at)->not->toBeNull(); +}); + +it('понимает отказ без числа частей', function () { + // parts не приходит при rejected и insufficient_balance. + $message = t2HookMessage(); + + $response = $this->get(t2HookUrl([ + 'id' => 'message-id-VvkmLHkfhCX9', + 'status' => 'insufficient_balance', + ])); + + $response->assertNoContent(); + expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::NOT_SENT); +}); + +it('не пускает чужой адрес и журнал не трогает', function () { + config(['services.sms.tele2.webhook_ips' => '203.0.113.10, 203.0.113.11']); + $message = t2HookMessage(); + + $response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9']) + ->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'])); + + // 404, а не 403: существование адреса чужому не подтверждаем. + $response->assertNotFound(); + expect($message->refresh()->delivery_status)->toBeNull(); +}); + +it('пускает адрес из списка разрешённых', function () { + config(['services.sms.tele2.webhook_ips' => '203.0.113.10, 198.51.100.0/24']); + $message = t2HookMessage(); + + $response = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.9']) + ->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'])); + + $response->assertNoContent(); + expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::DELIVERED); +}); + +/** + * Пока белый список пуст, ворота открыты всем — и адрес звонившего негде взять: + * отказа нет, значит и записи «пришли с такого-то адреса» нет. Круг замкнут, + * список не заполнить никогда. Разрываем его: при пустом списке пишем адрес. + */ +it('при пустом белом списке записывает адрес звонившего', function () { + config(['services.sms.tele2.webhook_ips' => '']); + Log::spy(); + t2HookMessage(); + + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77']) + ->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'])) + ->assertNoContent(); + + // Методы подставного журнала анализатору не видны — как у соседей по проекту. + /* @phpstan-ignore-next-line staticMethod.notFound */ + Log::shouldHaveReceived('notice') + ->withArgs(fn ($msg, $ctx) => $msg === 'client_sms.delivery_hook_open_gate' + && $ctx['provider'] === 'tele2' + && $ctx['ip'] === '203.0.113.77') + ->once(); +}); + +it('когда белый список задан — про адрес молчит', function () { + config(['services.sms.tele2.webhook_ips' => '203.0.113.0/24']); + Log::spy(); + t2HookMessage(); + + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.77']) + ->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'])) + ->assertNoContent(); + + /* @phpstan-ignore-next-line staticMethod.notFound */ + Log::shouldNotHaveReceived('notice'); +}); + +it('не пускает с неверным секретом и журнал не трогает', function () { + $message = t2HookMessage(); + + $response = $this->get(t2HookUrl( + ['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'], + 'sovsem-drugoy-sekret-0123456789abcd', + )); + + $response->assertNotFound(); + expect($message->refresh()->delivery_status)->toBeNull(); +}); + +it('закрыт наглухо, пока секрет не задан', function () { + config(['services.sms.tele2.webhook_secret' => '']); + $message = t2HookMessage(); + + // Пустой секрет — это «приёмник не настроен», а не «пускать всех». + $response = $this->get(t2HookUrl( + ['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'delivered'], + 'chto-ugodno-0123456789abcdefghij', + )); + + $response->assertNotFound(); + expect($message->refresh()->delivery_status)->toBeNull(); +}); + +it('на звонок про незнакомое сообщение не падает', function () { + $message = t2HookMessage(providerMessageId: 'message-id-VvkmLHkfhCX9'); + + $response = $this->get(t2HookUrl(['id' => 'message-id-CHUZHOY', 'status' => 'delivered'])); + + $response->assertNoContent(); + expect($message->refresh()->delivery_status)->toBeNull(); +}); + +it('незнакомое слово оператора судьбу НЕ меняет', function () { + $message = t2HookMessage(); + + $response = $this->get(t2HookUrl([ + 'id' => 'message-id-VvkmLHkfhCX9', + 'status' => 'kakoe_to_novoe_slovo', + ])); + + $response->assertNoContent(); + + $message->refresh(); + // Судьба пустая, слово сохранено как есть — угадать значило бы соврать про деньги. + expect($message->delivery_status)->toBeNull() + ->and($message->delivery_raw)->toBe('kakoe_to_novoe_slovo'); +}); + +it('на звонок непонятной формы не пишет ничего', function () { + $message = t2HookMessage(); + + $response = $this->get(t2HookUrl(['message_uid' => 'message-id-VvkmLHkfhCX9', 'state' => 'ok'])); + + $response->assertNoContent(); + + $message->refresh(); + expect($message->delivery_status)->toBeNull() + ->and($message->delivery_raw)->toBeNull() + ->and($message->delivery_checked_at)->toBeNull(); +}); + +it('денег НЕ двигает — возврат остаётся делом опроса', function () { + $message = t2HookMessage(); + + $response = $this->get(t2HookUrl(['id' => 'message-id-VvkmLHkfhCX9', 'status' => 'failed'])); + + $response->assertNoContent(); + expect($message->refresh()->delivery_status)->toBe(SmsDeliveryState::NOT_DELIVERED) + ->and($message->refresh()->refunded_at)->toBeNull() + // Деньги живут в ОДНОМ доме — в команде опроса (В-146). + // 🪤 Считаем проводки СВОЕГО клиента: рекламные тесты идут без отката и + // оставляют свои записи в базе (урок из MtsDeliveryWebhookTest). + ->and(AdWalletTransaction::where('tenant_id', $message->tenant_id)->count())->toBe(0); +}); diff --git a/app/tests/Feature/Sales/SmsRouterBuildTest.php b/app/tests/Feature/Sales/SmsRouterBuildTest.php index 6dd404be..d70dbadd 100644 --- a/app/tests/Feature/Sales/SmsRouterBuildTest.php +++ b/app/tests/Feature/Sales/SmsRouterBuildTest.php @@ -29,3 +29,32 @@ it('в бою поднимаются только включённые кана expect($router->pick('mts')?->key())->toBe('smsc') ->and($router->pick('')?->key())->toBe('smsc'); }); + +it('канал Теле2 встаёт, когда включён и есть логин с паролем', function () { + config()->set('services.sms.sandbox', false); + config()->set('services.smsc.login', 'l'); + config()->set('services.smsc.password', 'p'); + config()->set('services.sms.providers.smsc.enabled', true); + config()->set('services.sms.tele2.login', 'liderra_api'); + config()->set('services.sms.tele2.password', 'secret'); + config()->set('services.sms.providers.tele2.enabled', true); + config()->set('services.sms.providers.tele2.serves', ['tele2']); + app()->forgetInstance(SmsRouter::class); + + // Номер Теле2 уходит своему оператору, чужой — по-прежнему универсалу. + expect(app(SmsRouter::class)->pick('tele2')?->key())->toBe('tele2') + ->and(app(SmsRouter::class)->pick('beeline')?->key())->toBe('smsc'); +}); + +it('без логина Теле2 канал не встаёт и номер уходит универсалу', function () { + config()->set('services.sms.sandbox', false); + config()->set('services.smsc.login', 'l'); + config()->set('services.smsc.password', 'p'); + config()->set('services.sms.providers.smsc.enabled', true); + config()->set('services.sms.tele2.login', ''); // ключа нет → канал не встаёт + config()->set('services.sms.providers.tele2.enabled', true); + config()->set('services.sms.providers.tele2.serves', ['tele2']); + app()->forgetInstance(SmsRouter::class); + + expect(app(SmsRouter::class)->pick('tele2')?->key())->toBe('smsc'); +}); diff --git a/app/tests/Unit/Sms/T2SmsProviderTest.php b/app/tests/Unit/Sms/T2SmsProviderTest.php new file mode 100644 index 00000000..e643c84c --- /dev/null +++ b/app/tests/Unit/Sms/T2SmsProviderTest.php @@ -0,0 +1,345 @@ + 250], + baseUrl: 'https://target.t2.ru/api/v2', + naming: 'Liderra', + ); +} + +function t2_message(string $sender = 'Liderra', int $segments = 1): SmsOutgoing +{ + return new SmsOutgoing( + phone: '79990000001', + body: 'Здравствуйте!', + senderName: $sender, + operator: 'tele2', + segments: $segments, + ); +} + +/** Отправить и вернуть пойманное SmsSendException; если исключения не было — провалить тест. */ +function t2_thrown(): SmsSendException +{ + try { + t2_provider()->send(t2_message()); + } catch (SmsSendException $e) { + return $e; + } + + throw new RuntimeException('ожидалось SmsSendException, но отправка прошла без ошибки'); +} + +it('обслуживает только Теле2', function () { + expect(t2_provider()->servesOperators())->toBe(['tele2']) + ->and(t2_provider()->key())->toBe('tele2'); +}); + +it('успешная отправка возвращает номер сообщения и цену', function () { + Http::fake(['target.t2.ru/*' => Http::response( + ['result' => ['uid' => 'message-id-VvkmLHkfhCX9'], 'status' => 'ok'], + 200, + )]); + + $result = t2_provider()->send(t2_message(segments: 2)); + + expect($result->providerMessageId)->toBe('message-id-VvkmLHkfhCX9') + ->and($result->segments)->toBe(2) + ->and($result->costKopecks)->toBe(500); + + Http::assertSent(function ($request) { + return $request->url() === 'https://target.t2.ru/api/v2/send_message' + && $request->method() === 'POST' + // Номер уходит ЧИСЛОМ по формату 7nnnNNNnnNN — так в документации t2. + && $request['msisdn'] === 79990000001 + && $request['shortcode'] === 'Liderra' + && $request['text'] === 'Здравствуйте!' + && $request->hasHeader('Authorization', 'Basic '.base64_encode('liderra_api:secret')); + }); +}); + +it('отказ с кодом 200 в теле — это отказ, а не успех', function () { + // 🪤 t2 отвечает 200 даже на отказ: судить надо по полю status, а не по коду HTTP. + Http::fake(['target.t2.ru/*' => Http::response( + ['reason' => 'invalid_field_shortcode', 'status' => 'error'], + 200, + )]); + + $e = t2_thrown(); + + expect($e->getMessage())->toContain('invalid_field_shortcode') + ->and($e->terminal)->toBeTrue(); +}); + +it('сбой на стороне t2 — повтор осмыслен', function () { + Http::fake(['target.t2.ru/*' => Http::response('', 502)]); + + expect(t2_thrown()->terminal)->toBeFalse(); +}); + +it('связь оборвалась — это отказ канала, а не падение джоба', function () { + Http::fake(fn () => throw new ConnectionException('Connection timed out')); + + $e = t2_thrown(); + + expect($e->terminal)->toBeFalse() + ->and($e->getMessage())->toContain('Теле2 недоступен'); +}); + +it('внутренняя ошибка t2 — повтор осмыслен, в отличие от кривого запроса', function () { + // system_error = «не смогли сохранить сообщение», это не наша вина: через + // минуту получится. Остальные reason'ы — наши, повторять их бесполезно. + Http::fake(['target.t2.ru/*' => Http::response( + ['reason' => 'system_error', 'status' => 'error'], + 200, + )]); + + expect(t2_thrown()->terminal)->toBeFalse(); +}); + +// ─── Судьба сообщений: «дошло или нет» (строки листа 5.1–5.2) ──────────────── + +/** + * Ответ t2 на опрос об одном сообщении — форма из документации «t2 HTTP API v2». + * + * @return array + */ +function t2_pollBody( + string $status = 'delivered', + string $uid = 'message-id-VvkmLHkfhCX9', + mixed $pduCount = 2, +): array { + return [ + 'result' => [ + 'created_at' => 1574087003, + 'msisdn' => 79990000001, + 'pdu_count' => $pduCount, + 'status' => $status, + 'text' => 'Здравствуйте!', + 'uid' => $uid, + 'updated_at' => 1574087903, + ], + 'status' => 'ok', + ]; +} + +it('за один заход спрашивает про ОДНО сообщение — пакетного запроса у t2 нет', function () { + expect(t2_provider()->deliveryBatchSize())->toBe(1); +}); + +it('спрашивает судьбу по номеру сообщения и понимает ответ', function () { + Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(), 200)]); + + $reports = t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9']); + + expect($reports)->toHaveCount(1); + + $report = $reports[0]; + expect($report->providerMessageId)->toBe('message-id-VvkmLHkfhCX9') + ->and($report->phone)->toBe('79990000001') + ->and($report->state)->toBe(SmsDeliveryState::DELIVERED) + ->and($report->rawState)->toBe('delivered') + ->and($report->partCount)->toBe(2) + // Время приходит ЧИСЛОМ (UNIX), а не строкой даты. + ->and($report->happenedAt?->timestamp)->toBe(1574087903) + ->and($report->deliveredAt?->timestamp)->toBe(1574087903); + + Http::assertSent(function ($request) { + // Номер сообщения уже несёт приставку message-id- — второй раз её не клеим. + return $request->url() === 'https://target.t2.ru/api/v2/send_message/message-id-VvkmLHkfhCX9' + && $request->method() === 'GET' + && $request->hasHeader('Authorization', 'Basic '.base64_encode('liderra_api:secret')); + }); +}); + +it('про пустой список не спрашивает вовсе', function () { + Http::fake(); + + expect(t2_provider()->fetchDelivery([]))->toBe([]); + + Http::assertNothingSent(); +}); + +it('спрашивает по очереди про каждое сообщение', function () { + Http::fake([ + 'target.t2.ru/api/v2/send_message/message-id-A' => Http::response(t2_pollBody(uid: 'message-id-A'), 200), + 'target.t2.ru/api/v2/send_message/message-id-B' => Http::response( + t2_pollBody(status: 'failed', uid: 'message-id-B'), 200 + ), + ]); + + $reports = t2_provider()->fetchDelivery(['message-id-A', 'message-id-B']); + + expect($reports)->toHaveCount(2) + ->and($reports[0]->state)->toBe(SmsDeliveryState::DELIVERED) + ->and($reports[1]->state)->toBe(SmsDeliveryState::NOT_DELIVERED); + + Http::assertSentCount(2); +}); + +it('переводит слова t2 в судьбу портала', function (string $raw, ?string $expected) { + Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(status: $raw), 200)]); + + $report = t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9'])[0]; + + expect($report->state)->toBe($expected) + ->and($report->rawState)->toBe($raw); +})->with([ + ['delivered', SmsDeliveryState::DELIVERED], + ['awaiting_report', SmsDeliveryState::SENDING], + ['failed', SmsDeliveryState::NOT_DELIVERED], + // Оператор ПРИНЯЛ, но не отправил — за такое клиент платить не должен (В-212). + ['insufficient_balance', SmsDeliveryState::NOT_SENT], + ['rejected', SmsDeliveryState::NOT_SENT], + // Незнакомое слово НЕ угадываем: за «не доставлено» возвращаются деньги (В-198). + ['какое_то_новое_слово', null], +]); + +it('время доставки ставит только у дошедшего', function () { + Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(status: 'awaiting_report'), 200)]); + + $report = t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9'])[0]; + + expect($report->deliveredAt)->toBeNull() + // Момент последней перемены знаем всегда — он и есть happenedAt. + ->and($report->happenedAt?->timestamp)->toBe(1574087903); +}); + +it('пустое число частей не выдумывает', function () { + Http::fake(['target.t2.ru/*' => Http::response(t2_pollBody(pduCount: null), 200)]); + + expect(t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9'])[0]->partCount)->toBeNull(); +}); + +it('сбой при опросе — повтор осмыслен, судьбу можно переспросить позже', function () { + Http::fake(['target.t2.ru/*' => Http::response('', 500)]); + + try { + t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9']); + throw new RuntimeException('ожидалось SmsSendException'); + } catch (SmsSendException $e) { + expect($e->terminal)->toBeFalse(); + } +}); + +it('отказ t2 при опросе — тоже не окончательный', function () { + // 🪤 И здесь отказ приезжает с кодом 200: судить по полю status. + Http::fake(['target.t2.ru/*' => Http::response(['reason' => 'wrong_ip', 'status' => 'error'], 200)]); + + try { + t2_provider()->fetchDelivery(['message-id-VvkmLHkfhCX9']); + throw new RuntimeException('ожидалось SmsSendException'); + } catch (SmsSendException $e) { + expect($e->terminal)->toBeFalse() + ->and($e->getMessage())->toContain('wrong_ip'); + } +}); + +it('понимает обратный звонок t2 — он приходит строками в адресе, а не письмом', function () { + // t2 зовёт нас так: ...?id=&status=&parts= + $reports = t2_provider()->parseDeliveryPayload([ + 'id' => 'message-id-VvkmLHkfhCX9', + 'status' => 'delivered', + 'parts' => '2', + ]); + + expect($reports)->toHaveCount(1); + + $report = $reports[0]; + expect($report->providerMessageId)->toBe('message-id-VvkmLHkfhCX9') + ->and($report->state)->toBe(SmsDeliveryState::DELIVERED) + ->and($report->rawState)->toBe('delivered') + ->and($report->partCount)->toBe(2) + // Номера в звонке нет — выдумывать нечего, и для поиска строки он не нужен. + ->and($report->phone)->toBe(''); +}); + +it('обратный звонок без числа частей — так бывает при отказе', function () { + // parts не передаётся при rejected и insufficient_balance. + $report = t2_provider()->parseDeliveryPayload([ + 'id' => 'message-id-VvkmLHkfhCX9', + 'status' => 'rejected', + 'err' => 'blacklist', + ])[0]; + + expect($report->partCount)->toBeNull() + ->and($report->state)->toBe(SmsDeliveryState::NOT_SENT); +}); + +it('слово «ok» из конверта ответа за судьбу НЕ принимает', function () { + // 🪤 В ответе на опрос поле status встречается ДВАЖДЫ: снаружи это «запрос + // удался», внутри — судьба сообщения. Спутать их значит объявить доставленным + // всё подряд. + $reports = t2_provider()->parseDeliveryPayload(t2_pollBody(status: 'failed')); + + expect($reports)->toHaveCount(1) + ->and($reports[0]->state)->toBe(SmsDeliveryState::NOT_DELIVERED) + ->and($reports[0]->rawState)->toBe('failed'); +}); + +it('непонятную бумагу не разбирает — ноль записей, а не догадка', function () { + expect(t2_provider()->parseDeliveryPayload(['events_info' => [['x' => 1]]]))->toBe([]) + ->and(t2_provider()->parseDeliveryPayload([]))->toBe([]) + // Есть слово, но нет номера сообщения — правит нечего. + ->and(t2_provider()->parseDeliveryPayload(['status' => 'delivered']))->toBe([]); +}); + +/** + * 🔴 Имена отправителей регистрируются У КАЖДОГО ОПЕРАТОРА ОТДЕЛЬНО, и написание + * бывает разным: у МТС согласовано `liderra.ru`, у t2 — `Liderra.ru`. + * + * Клиентская рассылка несёт ОДНО имя на всех получателей (`campaign.sender_name`), + * и для t2 оно приезжает в чужом написании. Живой опыт на бою 04.08.2026: + * `liderra.ru` → отказ `invalid_source_address`, `Liderra.ru` → принято. + * + * Поэтому канал приводит написание к своему — но ТОЛЬКО когда это то же самое имя, + * отличающееся регистром. Чужое имя не трогаем: подменить его значило бы соврать + * про отправителя. + */ +it('имя отправителя приводит к написанию, согласованному с t2', function () { + Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]); + + t2_provider()->send(t2_message(sender: 'liderra')); + + Http::assertSent(fn ($request) => $request['shortcode'] === 'Liderra'); +}); + +it('точное совпадение имени не трогает', function () { + Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]); + + t2_provider()->send(t2_message(sender: 'Liderra')); + + Http::assertSent(fn ($request) => $request['shortcode'] === 'Liderra'); +}); + +it('чужое имя отправителя НЕ подменяет', function () { + // Когда у клиента появится своё согласованное имя, канал обязан слать его, + // а не наше. Отказ t2 в этом случае — честный ответ, а не повод врать. + Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]); + + t2_provider()->send(t2_message(sender: 'ChuzhoeImya')); + + Http::assertSent(fn ($request) => $request['shortcode'] === 'ChuzhoeImya'); +}); + +it('пустое имя заменяет своим', function () { + Http::fake(['target.t2.ru/*' => Http::response(['result' => ['uid' => 'u'], 'status' => 'ok'], 200)]); + + t2_provider()->send(t2_message(sender: '')); + + Http::assertSent(fn ($request) => $request['shortcode'] === 'Liderra'); +}); diff --git a/docs/superpowers/runbooks/2026-08-04-vklyuchenie-kanala-t2.md b/docs/superpowers/runbooks/2026-08-04-vklyuchenie-kanala-t2.md new file mode 100644 index 00000000..72361817 --- /dev/null +++ b/docs/superpowers/runbooks/2026-08-04-vklyuchenie-kanala-t2.md @@ -0,0 +1,263 @@ +# Включение канала Теле2 («SMS-Таргет») — памятка + +**Дата:** 04.08.2026 +**Ветка с кодом:** `feat/sms-t2-channel` +**Состояние:** код готов и проверен, канал ВЫКЛЮЧЕН, живьём не пробован ни разу. + +--- + +## 0. Что уже сделано (менять не надо) + +Канал Теле2 умеет обе половины работы: + +| Файл | Роль | +|---|---| +| `app/Services/Sms/Providers/T2SmsProvider.php` | отправка + опрос судьбы + разбор обратного звонка | +| `app/Http/Controllers/Api/T2DeliveryWebhookController.php` | приёмник звонков от t2 | +| `app/Providers/AppServiceProvider.php` | сборка канала | +| `config/services.php` | настройки `services.sms.tele2` + запись в реестре `providers.tele2` | +| `routes/web.php` | адрес приёмника | +| `.env.example` | ключи `SMS_T2_*` | + +Проверено: 34 теста зелёные, Pint чист, статанализ 0 ошибок, маршрут +`GET|POST api/webhook/t2-delivery/{secret}` зарегистрирован. + +**Ничего дополнительно заводить не нужно:** Теле2 уже был в списке разрешённых +операторов (`AllowedSmsOperators::DEFAULT`) и в тарифах админки +(`AdminSmsTariffController`). Миграций нет — графы судьбы (`delivery_status`, +`delivery_raw`, `provider_parts`, `delivered_at`, `delivery_checked_at`) +появились ещё при МТС. + +Ежедневный опрос `client-sms:poll-delivery` подхватит Теле2 сам — он берёт любой +канал, умеющий отвечать про судьбу. + +--- + +## 1. Чего ждём (без этого дальше нельзя) + +**Согласование имени отправителя `Liderra.ru`, заявка № 31121.** +Состояние на 04.08.2026 — **ОТКЛОНЕНО** (03.08, 17:59). + +Модератору отправлены (переписка в кабинете, вкладка «Открыть диалог переписки»): + +- 04.08, 07:03 — письмо-согласие правообладателя, подписанное обеими сторонами; +- 04.08, 07:48 — свидетельство о регистрации домена liderra.ru; +- 04.08, 08:27 — **оба документа одним сообщением** (модератор требует именно так). + +Причина первого отказа: домен принадлежит Кондратьеву Дмитрию Владимировичу, а +кабинет — ИП Кондратьев Платон Дмитриевич. Согласие правообладателя как раз это и +закрывает. + +Логин и пароль к API выдают **только после** согласования: кабинет → Профиль → +API-рассылки → Настроить подключение. + +--- + +## 2. Шаги после согласования — по порядку + +### 2.1. Забрать доступы и заявить адрес сервера + +Кабинет `target.t2.ru` (вход `info@liderra.ru`) → Профиль → API-рассылки → +Настроить подключение: + +- забрать **логин и пароль** (Basic Auth); +- **заявить IP боевого сервера**. 🪤 Без этого t2 отвечает `403 wrong_ip` на + каждую попытку — тот же капкан, что с МТС; +- если там же есть поле для обратного звонка — указать адрес из п. 2.2. + +### 2.2. Придумать секрет приёмника + +Секрет **не короче 32 знаков**, только латиница/цифры/`_`/`-` (ограничение +маршрута). Короче 32 или пустой = приёмник закрыт наглухо, звонки не принимаются. + +Полный адрес для t2: + +``` +https://liderra.ru/api/webhook/t2-delivery/<секрет> +``` + +t2 добавит к нему `?id=&status=<статус>&parts=<частей>`. + +🪤 **t2 наш ответ не проверяет и повторов НЕ делает.** Пропущенный звонок потерян +навсегда — судьбу доберёт только опрос. Поэтому приёмник ускоряет, но не заменяет +`client-sms:poll-delivery`. + +### 2.3. Цена — сравнивать НЕ с чем + +Пакет: **1500 SMS на t2 России, куплен, срок до 03.09.2026, 10 380 ₽** → +**6,92 ₽ за смс** → `SMS_T2_PRICE_KOP=692`. + +**Проверено на живом сервере 04.08.2026** — не по `.env`, а спросом у самого +маршрутизатора (`config('services.sms.providers')` через tinker), потому что поиск +по `.env` обманчив: ключи СМС-центра зовутся `SMSC_*`, а не `SMS_SMSC_*`, и поиск +по `SMS_` их не видит: + +``` +smsc: enabled=false, serves=["*"], price_kopecks=0 ← НАСТРОЕН, но ВЫКЛЮЧЕН +mts: enabled=true, serves=["mts"], price_kopecks=503 +sandbox=false, failover_to_universal=false, SMSC_LOGIN задан +``` + +**Универсальный канал СМС-центра существует и настроен — он просто выключен.** +Именно через него абоненты Теле2 обслуживались бы «по умолчанию»; сегодня не +обслуживаются, потому что канал выключен (а сверх того их отсекает +`allowed_operators`, см. 2.3-бис). + +Значит, до Теле2 есть **два разных пути**, и это выбор владельца: + +| Путь | Цена клиенту | Что нужно | +|---|---|---| +| Свой канал t2 | 6,92 ₽ | согласование имени + доступы + шаги этой памятки | +| Включить универсал обратно | ⚠️ **сейчас 0** | `SMS_SMSC_ENABLED=true` + обязательно задать цену | + +🪤 **У СМС-центра `price_kopecks = 0`**, потому что `SMS_SMSC_PRICE_KOP` не задан. +Включить универсал, не выставив цену, значит **возить смс клиентам бесплатно за наш +счёт**. Цену ставить в тот же заход, что и тумблер. + +МТС по 5,03 ₽ ни тому, ни другому не конкурент — он обслуживает только своих +абонентов, маршрутизатор их не смешивает. + +### 2.3-бис. 🔴 Разрешить оператора Теле2 в настройках рассылок + +**Без этого шага канал не заработает, даже если всё остальное сделано.** + +В `client_sms_settings` (одна строка, `id=1`) сейчас: + +``` +allowed_operators = ["mts"] +``` + +Поставлено решением владельца 03.08.2026, потому что имя `liderra.ru` подключено у +МТС **только на одного оператора**, и абонентам прочих сообщения не дошли бы. +Пока стоит так, номера Теле2 **отсеиваются до отправки** — до канала дело не +доходит вовсе. + +Когда имя t2 согласовано и канал настроен — дописать `tele2`: + +``` +["mts", "tele2"] +``` + +Проверить вырезанием: при `["mts"]` номер Теле2 должен отсеиваться, при +`["mts","tele2"]` — доходить до канала. + +### 2.4. Прописать настройки на боевом + +``` +SMS_T2_LOGIN=<логин из кабинета> +SMS_T2_PASSWORD=<пароль из кабинета> +SMS_T2_NAMING=Liderra +SMS_T2_PRICE_KOP=692 +SMS_T2_WEBHOOK_SECRET=<секрет из 2.2> +SMS_T2_WEBHOOK_IPS= +SMS_T2_ENABLED=false # включать ПОСЛЕДНИМ шагом, после проверки +``` + +`SMS_T2_WEBHOOK_IPS` оставить пустым: адреса, с которых звонит t2, нам неизвестны, +выдумывать нельзя. Пустой список = не проверяем, защита держится на секрете. + +**Откуда взять адреса.** Пока список пуст, приёмник на КАЖДЫЙ звонок пишет в журнал +строку `client_sms.delivery_hook_open_gate` с адресом звонившего: + +``` +ssh liderra-prod "grep -h delivery_hook_open_gate /var/www/liderra/app/storage/logs/laravel*.log | tail -50" +``` + +Собрать адреса, вписать в `SMS_T2_WEBHOOK_IPS` через запятую (можно подсетями), +`php artisan config:clear`. После этого строка смолкает сама — шуметь в журнале +постоянно она не будет. + +🪤 Раньше здесь был замкнутый круг: при пустом списке отказа нет, значит и записи +об адресе нет — заполнить список было нечем. Закрыто 04.08.2026. + +### 2.5. Выкатить код + +🪤 **Боевой сервер бывает ВПЕРЕДИ ветки** — собирать фронт надо из боевого кода, +положив поверх только свои файлы. Порядок — память +`feedback-vykat-kogda-boy-vperedi-vetki`. + +После распаковки: `config:clear` (🪤 от root молча не срабатывает — проверить, что +настройки реально перечитались). + +### 🪤 2.5-бис. ПЕРЕЗАПУСТИТЬ ФОНОВОГО ИСПОЛНИТЕЛЯ + +**Обожглись 04.08.2026.** Настройки поменяли, `config:cache` сделали, php-fpm +перезагрузили — и первая же клиентская рассылка на Теле2 пропустила номер с +пометкой `skipped_no_route`. + +Причина: рассылку выполняет **не веб-часть, а фоновый исполнитель** +`liderra-queue.service`. Он читает настройки ОДИН раз при запуске и держит их в +памяти. `systemctl reload php8.3-fpm` его не касается. + +``` +исполнитель запущен 10:23:30 ← настроек про Теле2 ещё нет +канал включён 10:47 +рассылка клиента 10:51:17 → skipped_no_route +``` + +Лечение — **после каждой правки `.env`, влияющей на рассылки**: + +``` +sudo systemctl restart liderra-queue +``` + +Датчик, что помогло, — спросить маршрутизатор тем же кодом, каким ходит +исполнитель: `pick("tele2")` должен вернуть `tele2`, а не пустоту. + +🪤 Рядом живут ещё три исполнителя (`liderra-queue-bot`, +`liderra-queue-autopodbor-find`, `-study`). Рассылок они не выполняют, но если +правка настроек касается и их — перезапускать надо и их тоже. + +### 2.6. Живая проверка — обязательно глазами + +1. `SMS_T2_ENABLED=true`, `config:clear`. +2. Отправить **одну** смс на настоящий номер Теле2 (свой). +3. Убедиться, что смс **дошла на телефон** и имя отправителя — `Liderra`. +4. В журнале `client_sms_messages` у строки должны появиться `provider_key=tele2` + и `provider_message_id` вида `message-id-…`. +5. Дождаться обратного звонка или прогона опроса — судьба должна стать + `delivered`. + +🔴 «Отправилось» ≠ «дошло». Пока смс не увидена на экране телефона, канал не +принят. Все тесты гоняют *придуманный* ответ t2 — как отвечает живой, не проверял +никто. + +--- + +## 3. Что делать, если пойдёт не так + +| Признак | Причина | Лечение | +|---|---|---| +| `403 wrong_ip` | адрес сервера не заявлен | п. 2.1 | +| t2 отвечает 200, но смс не уходит | отказ приезжает с кодом «успех» — смотреть поле `status` в теле | канал это уже учитывает, читать `reason` в журнале | +| судьба не меняется | звонки не доходят (секрет/адрес) | судьбу доберёт опрос; проверить адрес и секрет | +| `client_sms.delivery_hook_unparsed` в журнале | форма звонка не та, что в документации | сверить имена полей в записи журнала — значения там не пишутся | +| незнакомое слово статуса | у t2 появился новый | сохраняется в `delivery_raw`, судьбу не двигает — добавить перевод в `mapDeliveryState` | + +## 4. Откат + +`SMS_T2_ENABLED=false` + `config:clear`, и вернуть `allowed_operators` в `["mts"]`. +Код при этом трогать не нужно. + +После отката абонентам Теле2 снова не уходит ничего — ровно как сегодня. +Запасного пути на автомате нет: `failover_to_universal=false`, а универсальный +канал СМС-центра выключен. Если нужен именно запасной путь — это отдельное решение +(включить универсал и **задать ему цену**, см. 2.3). + +--- + +## 5. Открытое + +- **Срок хранения статусов у t2 неизвестен.** Опрос перестаёт спрашивать про + сообщения старше трёх суток — срок взят от МТС. Выяснится на живых данных. +- **Глагол обратного звонка не проверен.** Документация показывает адрес со + строками запроса, но не говорит, GET это или POST. Приёмник принимает оба. +- **Адреса, с которых звонит t2, неизвестны** — но теперь собираются сами: строка + `client_sms.delivery_hook_open_gate` в журнале, см. 2.4. Останется вписать. +- **Имя отправителя одно на все каналы.** У МТС оно `liderra.ru`, у t2 `Liderra.ru`; + клиентская рассылка несёт одно имя всем. Канал Теле2 сам приводит написание к + своему, если это то же имя в другом регистре, — но если у оператора имя окажется + ДРУГИМ словом, а не другим регистром, отправка снова упрётся в отказ + `invalid_source_address`. Правильное решение — реестр имён по каналам и в + клиентском модуле; в модуле отдела продаж он уже есть + (`SalesSmsSender::activeByProviderKey`). Отдельная работа, здесь не делалась.