fix(сторож секретов): приёмочные документы в корне docs/superpowers/ вставали насмерть у всех

Полная проверка истории находила 5 «незамаскированных телефонов» в
docs/superpowers/2026-07-27-PRIEMKA-client-sms-fixes.md и не пускала push НИКОГО.

Замерено: все пять с префиксом 7999 — синтетические по правилу проекта
(«реальные НИКОГДА»). Настоящих персональных данных нет.

Причина: список исключений покрывает подпапки docs/superpowers/{plans,runbooks,
specs,audits,findings,prototypes}, а этот документ лежит ПРЯМО в docs/superpowers/
и под них не подпадал. Файл из истории не убрать, не переписав общую ветку, —
значит, чинить надо список.

Разрешение узкое: только docs/superpowers/ГГГГ-ММ-ДД-PRIEMKA-*.md. Прочие файлы
корня docs/superpowers/ проверяются по-прежнему.

Проверено: до правки — 5 находок, после — «no leaks found» на 7589 записях.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-04 21:00:14 +03:00
10 changed files with 1524 additions and 0 deletions
+14
View File
@@ -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).
# Песочница по умолчанию ВКЛ — робот доводит только до черновика, деньги не списываются.
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\ClientSms\ClientSmsDeliveryWriter;
use App\Services\Sms\SmsDeliveryPayloadParser;
use App\Services\Sms\SmsRouter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Обратный звонок Теле2 о судьбе сообщения (строка листа 5.2).
*
* URL: GET|POST /api/webhook/t2-delivery/{secret}?id=<uid>&status=<status>&parts=<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<mixed> $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);
}
}
+11
View File
@@ -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,
};
}
@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
namespace App\Services\Sms\Providers;
use App\Services\Sms\SmsDeliveryPayloadParser;
use App\Services\Sms\SmsDeliveryReport;
use App\Services\Sms\SmsDeliveryReporter;
use App\Services\Sms\SmsDeliveryState;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsProvider;
use App\Services\Sms\SmsSendException;
use App\Services\Sms\SmsSendResult;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
use Throwable;
/**
* Канал Теле2 (бренд t2, ООО «Т2 Мобайл») через HTTP API сервиса «SMS-Таргет».
* Обслуживает только номера Теле2 прямой оператор дешевле универсала.
*
* Тело: {"msisdn":79990000001,"shortcode":"<имя отправителя>","text":""}.
* Ответ: {"result":{"uid":"message-id-…"},"status":"ok"}.
*
* @param array<int, string> $serves
* @param array<string, int> $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<int, string> $providerMessageIds
* @return array<int, SmsDeliveryReport>
*/
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<int, SmsDeliveryReport>
*/
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=<uid>&status=<status>&parts=<parts>`
* (плюс `err` при `rejected`; `parts` не приходит при `rejected` и
* `insufficient_balance`).
*
* 🪤 Слово `status` в ответе на опрос встречается ДВАЖДЫ: снаружи это «запрос
* удался», внутри `result` судьба сообщения. Спутать их значит объявить
* доставленным всё подряд, поэтому наружное сюда не заглядывает вовсе.
*
* 🔴 Не узнали бумагу ПУСТОЙ список. Ноль безопасен: пропущенное доберёт
* опрос, а выдуманное двигало бы ДЕНЬГИ (за «не доставлено» клиенту
* возвращаются рубли, В-198).
*
* @param array<mixed> $body
* @return array<int, SmsDeliveryReport>
*/
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<mixed> $result
* @return array<int, SmsDeliveryReport>
*/
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<mixed> $params
* @return array<int, SmsDeliveryReport>
*/
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;
}
}
+25
View File
@@ -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)],
],
],
],
+9
View File
@@ -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');
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
use App\Models\AdWalletTransaction;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Services\Sms\Providers\T2SmsProvider;
use App\Services\Sms\SmsDeliveryState;
use App\Services\Sms\SmsRouter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Tests\Concerns\SharesSupplierPdo;
/**
* Строка листа 5.2 для Теле2: t2 сам зовёт нас при каждой смене состояния.
*
* 🔴 Форма у t2 ДРУГАЯ, чем у МТС: это не письмо с телом, а строки в адресе
* `?id=<uid>&status=<status>&parts=<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<string, string> $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);
});
@@ -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');
});
+345
View File
@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
use App\Services\Sms\Providers\T2SmsProvider;
use App\Services\Sms\SmsDeliveryState;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsSendException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
function t2_provider(): T2SmsProvider
{
return new T2SmsProvider(
login: 'liderra_api',
password: 'secret',
serves: ['tele2'],
priceKopecks: ['*' => 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<string, mixed>
*/
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=<uid>&status=<status>&parts=<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');
});
@@ -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=<uid>&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`). Отдельная работа, здесь не делалась.