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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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`). Отдельная работа, здесь не делалась.
|
||||
Reference in New Issue
Block a user