diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index b85921df..57d24c30 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-30T09:36:32.961Z +Last updated: 2026-07-31T02:26:28.726Z | Контролёр | Состояние | Детали | |---|---|---| @@ -39,7 +39,7 @@ Last updated: 2026-07-30T09:36:32.961Z - Observer evidence: 0 episodes this month, 0 observer_error markers, 0 PII matches before filter - Legacy v1 episodes (not in factor analysis): 0 -- Last /brain-retro: 64 day(s) ago +- Last /brain-retro: 65 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). ## Метрики дисциплины @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3544 | MsMpEng | 8.05ч | 0.0ч | -| 23936 | Code | 2.15ч | NaNч | -| 4 | System | 1.33ч | 0.0ч | +| 3544 | MsMpEng | 10.31ч | 0.0ч | +| 23936 | Code | 3.49ч | NaNч | +| 4 | System | 1.95ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/plans/2026-07-31-tg-robot-poll-link.md b/docs/superpowers/plans/2026-07-31-tg-robot-poll-link.md new file mode 100644 index 00000000..7ae5a40b --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-tg-robot-poll-link.md @@ -0,0 +1,1899 @@ +# Связка портал → телеграм-робот через опрос — план внедрения + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Робот кабинета МТС перестаёт быть программой, запускаемой порталом на своей же машине, и начинает сам ходить к порталу за заданиями — как это уже сделано у робота креативов Яндекса. + +**Architecture:** Портал кладёт задание в новую таблицу `client_tg_robot_jobs` и на этом заканчивает свою очередь. Робот на своей машине раз в минуту дёргает `GET /api/tg-robot/next` по сервис-токену, скачивает файл номеров отдельным запросом, отрабатывает уже существующим `runner.js` и отчитывается в `POST /api/tg-robot/jobs/{id}/done`. Портал применяет итог тем же кодом, что и сегодня. Переключатель `client_tg.robot.transport` (`process` | `poll`) оставляет старый путь рабочим, пока новый не проверен. + +**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 с RLS, Pest 4, Node 20 + Playwright. + +--- + +## Зачем это вообще (контекст, читать до кода) + +Сегодня `TelegramRobotRunner::run()` запускает `node bin/run.js` через Symfony Process — то есть **робот обязан жить на той же машине, где крутится очередь портала**. Это не работает ни в одном из возможных исходов: + +- На боевом сервере портала нет ни браузера, ни разрешённого адреса: МТС отбивает адреса дата-центров (замер 30.07, 4 прогона — `2026-07-30-HANDOFF-telegram-dobit.md`). +- Если робот поселится на рендер-сервере — он всё равно на другой машине, чем очередь портала. +- Если робот останется на машине владельца — тем более другая машина. + +Значит опрос нужен при любом решении по адресу. Это и делает план разблокированной работой. + +**Образец, с которого списываем** — робот креативов Яндекса, он уже год живёт по этой схеме и проверен на бою: + +| Что | Где смотреть | +|---|---| +| Таблица заданий | `app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php` | +| Выдача строго по одному | `app/app/Services/Advertising/CreativeJobService.php` метод `takeNext` | +| Сервис-токен | `app/app/Http/Middleware/CreativeRobotToken.php` | +| Канал | `app/routes/web.php` группа `api/creative-robot` | +| Робот на сервере | `/opt/liderra-creative-robot` на `51.250.1.97`, systemd-таймер раз в 2 минуты | + +**Границы этого плана.** Здесь только режим запуска кампании (`draft` / `live`). Режимы `read-status` (чтение вердикта модерации) и `resubmit` (пересдача) переводятся на опрос **отдельным планом** — у них другой состав задания и другой разбор итога. До того момента `PollTelegramModerationJob` и `ResubmitTelegramCampaignJob` продолжают работать через процесс; это не поломка, а сознательная граница. + +## 🔴 Красные линии + +- **Выкат на бой — только с разрешения владельца.** База по умолчанию только чтение. +- **После выката миграции ПЕРЕзапустить `db/03_service_bypass_policies.sql`.** Одной `tenant_isolation` мало: служебные роли на бою НЕ BYPASSRLS, и без этого файла канал робота увидит в новой таблице **ноль строк молча**. Этот класс ошибки уже ловили (`ПИЛОТ.md`, память `project-reklama-modul-vykat-2026-07-25`). +- **Номера телефонов — ПДн (152-ФЗ).** В `payload` задания их класть нельзя: `payload` попадает в журналы. Номера отдаются отдельным запросом, только пока задание в работе, и только по токену. +- **Тестовая база общая на все ветки и отстаёт.** Перед прогоном Pest — `php artisan migrate --database=pgsql_testing`, иначе тесты врут «столбца нет». + +## Как в этом проекте пишут тесты телеграма (списано с живого файла) + +Фабрики у `ClientTg\Campaign` **нет** — кампания создаётся руками, обязательные поля видны +в `app/tests/Feature/ClientTg/CampaignChargeServiceTest.php:24-36`. Во всех тестах ниже +используется вот эта шапка, повторять её в каждом файле: + +```php +uses(RefreshDatabase::class); + +function tgJobCampaign(int $tenantId, string $status = Campaign::STATUS_QUEUED): Campaign +{ + return Campaign::query()->create([ + 'tenant_id' => $tenantId, + 'status' => $status, + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '315.00', + 'created_by' => 1, + ]); +} +``` + +🪤 Без `DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id)` построчная защита +вернёт **ноль строк молча** — тест упадёт с «нет кампании», хотя код верный. + +## Карта файлов + +**Создать:** + +| Файл | За что отвечает | +|---|---| +| `app/database/migrations/2026_07_31_100000_create_client_tg_robot_jobs.php` | таблица очереди заданий роботу + RLS + гранты | +| `app/app/Models/ClientTg/RobotJob.php` | модель задания | +| `app/app/Services/ClientTg/TelegramRobotQueue.php` | поставить задание / выдать очередное | +| `app/app/Services/ClientTg/TelegramCampaignResultApplier.php` | применить итог робота к кампании (вынуто из джоба) | +| `app/app/Http/Middleware/TgRobotToken.php` | сервис-токен канала | +| `app/app/Http/Controllers/Api/TgRobotController.php` | `next` / `phones` / `done` | +| `bots/mts-telegram-ads/src/portal.js` | разговор робота с порталом | +| `bots/mts-telegram-ads/bin/poll.js` | один проход опроса | + +**Изменить:** + +| Файл | Что | +|---|---| +| `app/config/client_tg.php` | + `robot.transport`, + `robot.poll_lease_minutes` | +| `app/config/services.php` | + блок `tg_robot.token` | +| `app/bootstrap/app.php` | + алиас `tg-robot`, + исключение канала из CSRF | +| `app/routes/web.php` | + группа маршрутов `api/tg-robot` | +| `app/app/Jobs/ClientTg/RunTelegramCampaignJob.php` | развилка по `transport` | +| `bots/mts-telegram-ads/.env.example` | + `PORTAL_BASE_URL`, `TG_ROBOT_TOKEN` | +| `db/CHANGELOG_schema.md` | запись о новой таблице | + +--- + +### Задача 1: Переключатель канала и токен + +**Файлы:** + +- Изменить: `app/config/client_tg.php:56-61` +- Изменить: `app/config/services.php` (рядом с блоком `creative_robot`) +- Тест: `app/tests/Feature/ClientTg/RobotTransportConfigTest.php` + +- [ ] **Шаг 1: Пишем падающий тест** + +```php +toBe('process'); +}); + +it('канал переключается переменной окружения', function () { + config()->set('client_tg.robot.transport', 'poll'); + expect(config('client_tg.robot.transport'))->toBe('poll'); +}); + +it('сервис-токен робота по умолчанию пуст — канал закрыт', function () { + expect(config('services.tg_robot.token'))->toBe(''); +}); +``` + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/RobotTransportConfigTest.php` +Ожидаем: FAIL — `config('client_tg.robot.transport')` возвращает `null`. + +- [ ] **Шаг 3: Правим `app/config/client_tg.php`** + +В блок `'robot' => [ ... ]` добавить два ключа: + +```php + /* + | Как портал отдаёт работу роботу. + | 'process' — старый путь: запускаем node на СВОЕЙ машине (Symfony Process). + | Работает, только если робот стоит там же, где очередь портала. + | 'poll' — робот на своей машине сам ходит за заданиями в /api/tg-robot/*. + | Единственный рабочий путь, когда робот и портал на разных машинах. + */ + 'transport' => (string) env('TG_ROBOT_TRANSPORT', 'process'), + + /* + | Сколько минут задание считается «в работе», прежде чем его можно выдать заново. + | Робот может умереть, не отчитавшись; без этого задание зависло бы навсегда. + | Прогон робота идёт ~5 минут, берём с запасом. + */ + 'poll_lease_minutes' => (int) env('TG_ROBOT_LEASE_MINUTES', 20), +``` + +- [ ] **Шаг 4: Правим `app/config/services.php`** + +Сразу после блока `'creative_robot' => [...]` добавить: + +```php + // Служебный канал «Телеграм-робот кабинета МТС → Портал». Пусто → канал закрыт (401). + 'tg_robot' => [ + 'token' => env('TG_ROBOT_TOKEN', ''), + ], +``` + +- [ ] **Шаг 5: Тест зелёный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/RobotTransportConfigTest.php` +Ожидаем: PASS, 3 теста. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/config/client_tg.php app/config/services.php app/tests/Feature/ClientTg/RobotTransportConfigTest.php +git commit -F - <<'MSG' +feat телеграм-робот: переключатель канала process/poll и сервис-токен + +Пока только настройки, поведение не меняется: по умолчанию старый процессный путь. +MSG +``` + +--- + +### Задача 2: Таблица заданий роботу + +**Файлы:** + +- Создать: `app/database/migrations/2026_07_31_100000_create_client_tg_robot_jobs.php` +- Изменить: `db/CHANGELOG_schema.md` +- Тест: `app/tests/Feature/ClientTg/RobotJobsTableTest.php` + +- [ ] **Шаг 1: Пишем падающий тест** + +```php +toBeTrue(); + + foreach ([ + 'id', 'tenant_id', 'campaign_id', 'mode', 'payload', 'status', + 'attempts', 'result', 'failure_reason', 'taken_at', 'finished_at', + ] as $column) { + expect(Schema::hasColumn('client_tg_robot_jobs', $column))->toBeTrue("нет столбца {$column}"); + } +}); + +it('на таблице включена построчная защита с политикой по тенанту', function () { + $rls = DB::selectOne("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'client_tg_robot_jobs'"); + expect($rls->relrowsecurity)->toBeTrue(); + expect($rls->relforcerowsecurity)->toBeTrue(); + + $policy = DB::selectOne("SELECT polname FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid WHERE c.relname = 'client_tg_robot_jobs'"); + expect($policy->polname)->toBe('tenant_isolation'); +}); +``` + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/RobotJobsTableTest.php` +Ожидаем: FAIL — таблицы нет. + +- [ ] **Шаг 3: Пишем миграцию** + +```php +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('campaign_id')->constrained('client_tg_campaigns')->cascadeOnDelete(); + $table->string('mode', 16); // draft | live + $table->jsonb('payload'); // текст, ссылка, смета — БЕЗ номеров + $table->string('status', 16)->default('queued'); // queued → taken → done | failed + $table->unsignedSmallInteger('attempts')->default(0); + $table->jsonb('result')->nullable(); // сырой ответ робота + $table->string('failure_reason', 1024)->nullable(); + $table->timestamp('taken_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'campaign_id']); + $table->index('status'); + }); + + DB::statement('ALTER TABLE client_tg_robot_jobs ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE client_tg_robot_jobs FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_robot_jobs'); + DB::statement("CREATE POLICY tenant_isolation ON client_tg_robot_jobs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + // Гранты — под гардом на существование роли: на dev/тестах ходит суперпользователь + // postgres, ролей там нет, и голый GRANT уронил бы миграцию. + DB::statement(<<<'SQL' + DO $$ + BEGIN + -- Портал ставит задания при запуске кампании. + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + GRANT SELECT, INSERT, UPDATE ON client_tg_robot_jobs TO crm_app_user; + END IF; + + -- Канал робота работает под crm_admin_user (посредник admin-db). + -- Заданий робот не создаёт — только берёт и отчитывается: INSERT ему не даём. + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + GRANT SELECT, UPDATE ON client_tg_robot_jobs TO crm_admin_user; + END IF; + END + $$; + SQL); + + // Нумератор — отдельный объект со своими правами. Без USAGE на нём INSERT падает + // на бою с «permission denied for sequence», а на dev дырка невидима. + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + IF EXISTS ( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'client_tg_robot_jobs_id_seq' AND c.relkind = 'S' AND n.nspname = 'public' + ) THEN + GRANT USAGE, SELECT ON SEQUENCE public.client_tg_robot_jobs_id_seq TO crm_app_user; + END IF; + END IF; + END + $$; + SQL); + } + + public function down(): void + { + Schema::dropIfExists('client_tg_robot_jobs'); + } +}; +``` + +> Имя таблицы кампаний проверено 31.07: `App\Models\ClientTg\Campaign` объявляет +> `protected $table = 'client_tg_campaigns'` (`app/app/Models/ClientTg/Campaign.php:26`), +> так что внешний ключ в миграции указан верно. + +- [ ] **Шаг 4: Прогоняем миграции на тестовой базе и запускаем тест** + +Запустить: + +```bash +cd app && php artisan migrate --database=pgsql_testing --force +./vendor/bin/pest tests/Feature/ClientTg/RobotJobsTableTest.php +``` + +Ожидаем: PASS, 2 теста. + +- [ ] **Шаг 5: Записываем в журнал схемы** + +В `db/CHANGELOG_schema.md` добавить запись v9.10 с новой таблицей, политикой `tenant_isolation`, грантами и напоминанием про повторный прогон `db/03_service_bypass_policies.sql`. + +- [ ] **Шаг 6: Ревью защиты** + +Позвать агента `rls-reviewer` на изменения в `app/database/migrations/` — это обязательный контракт проекта при добавлении таблиц с RLS. + +- [ ] **Шаг 7: Коммит** + +```bash +git add app/database/migrations/2026_07_31_100000_create_client_tg_robot_jobs.php app/tests/Feature/ClientTg/RobotJobsTableTest.php db/CHANGELOG_schema.md +git commit -F - <<'MSG' +feat телеграм-робот: таблица заданий роботу с построчной защитой + +Номера телефонов в задание не кладём — только текст, ссылка и смета. +После выката на бой перезапустить db/03_service_bypass_policies.sql. +MSG +``` + +--- + +### Задача 3: Модель задания + +**Файлы:** + +- Создать: `app/app/Models/ClientTg/RobotJob.php` +- Тест: `app/tests/Feature/ClientTg/RobotJobModelTest.php` + +- [ ] **Шаг 1: Пишем падающий тест** + +```php +toBe('queued'); + expect(RobotJob::STATUS_TAKEN)->toBe('taken'); + expect(RobotJob::STATUS_DONE)->toBe('done'); + expect(RobotJob::STATUS_FAILED)->toBe('failed'); + + $job = new RobotJob(['payload' => ['adText' => 'привет']]); + expect($job->payload)->toBeArray()->toHaveKey('adText'); +}); +``` + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/RobotJobModelTest.php` +Ожидаем: FAIL — класс `App\Models\ClientTg\RobotJob` не найден. + +- [ ] **Шаг 3: Пишем модель** + +```php + 'array', + 'result' => 'array', + 'attempts' => 'integer', + 'taken_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } +} +``` + +- [ ] **Шаг 4: Тест зелёный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/RobotJobModelTest.php` +Ожидаем: PASS, 1 тест. + +- [ ] **Шаг 5: Коммит** + +```bash +git add app/app/Models/ClientTg/RobotJob.php app/tests/Feature/ClientTg/RobotJobModelTest.php +git commit -m "feat телеграм-робот: модель задания роботу" +``` + +--- + +### Задача 4: Очередь заданий — поставить и выдать + +**Файлы:** + +- Создать: `app/app/Services/ClientTg/TelegramRobotQueue.php` +- Тест: `app/tests/Feature/ClientTg/TelegramRobotQueueTest.php` + +- [ ] **Шаг 1: Пишем падающий тест** + +```php +create([ + 'tenant_id' => $tenantId, + 'status' => $status, + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '315.00', + 'created_by' => 1, + ]); +} + +// Тенант и кампания настоящие: у задания внешние ключи на обе таблицы, выдуманные +// номера база просто не пропустит. +beforeEach(function () { + $this->tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + $this->campaign = tgJobCampaign($this->tenant->id); +}); + +it('ставит задание и не задваивает его для одной кампании', function () { + $queue = app(TelegramRobotQueue::class); + + $first = $queue->enqueue($this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, ['adText' => 'а']); + $second = $queue->enqueue($this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, ['adText' => 'а']); + + expect($second->id)->toBe($first->id); + expect(RobotJob::where('campaign_id', $this->campaign->id)->count())->toBe(1); +}); + +it('выдаёт очередное задание и переводит его в работу', function () { + $queue = app(TelegramRobotQueue::class); + $queue->enqueue($this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, []); + + $taken = $queue->takeNext(); + + expect($taken)->not->toBeNull(); + expect($taken->status)->toBe(RobotJob::STATUS_TAKEN); + expect($taken->taken_at)->not->toBeNull(); + expect($taken->attempts)->toBe(1); +}); + +it('второму роботу не выдаёт задание, пока первое в работе', function () { + $second = tgJobCampaign($this->tenant->id); + $queue = app(TelegramRobotQueue::class); + $queue->enqueue($this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, []); + $queue->enqueue($this->tenant->id, $second->id, RobotJob::MODE_DRAFT, []); + + expect($queue->takeNext())->not->toBeNull(); + expect($queue->takeNext())->toBeNull(); +}); + +it('возвращает в очередь задание, зависшее дольше срока аренды', function () { + config()->set('client_tg.robot.poll_lease_minutes', 20); + $queue = app(TelegramRobotQueue::class); + $job = $queue->enqueue($this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, []); + $queue->takeNext(); + + RobotJob::where('id', $job->id)->update(['taken_at' => now()->subMinutes(21)]); + + $again = $queue->takeNext(); + expect($again)->not->toBeNull(); + expect($again->id)->toBe($job->id); + expect($again->attempts)->toBe(2); +}); +``` + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TelegramRobotQueueTest.php` +Ожидаем: FAIL — класса `TelegramRobotQueue` нет. + +- [ ] **Шаг 3: Пишем сервис** + +```php + $payload + */ + public function enqueue(int $tenantId, int $campaignId, string $mode, array $payload): RobotJob + { + $pending = RobotJob::where('campaign_id', $campaignId) + ->whereIn('status', [RobotJob::STATUS_QUEUED, RobotJob::STATUS_TAKEN]) + ->first(); + + if ($pending !== null) { + return $pending; + } + + return RobotJob::create([ + 'tenant_id' => $tenantId, + 'campaign_id' => $campaignId, + 'mode' => $mode, + 'payload' => $payload, + 'status' => RobotJob::STATUS_QUEUED, + ]); + } + + /** + * Выдаёт очередное задание, переводя его в работу. + * + * Замок берём ПЕРВОЙ строкой транзакции и именно advisory, а не по строке: защищать + * надо саму операцию выдачи, а строк с нужным статусом в этот момент может не быть + * вовсе. Без замка две одновременные выдачи обе прошли бы проверку «в работе никого». + */ + public function takeNext(): ?RobotJob + { + return DB::transaction(function (): ?RobotJob { + DB::select("SELECT pg_advisory_xact_lock(hashtext('client_tg_robot_jobs_take'))"); + + $leaseMinutes = (int) config('client_tg.robot.poll_lease_minutes', 20); + $deadline = now()->subMinutes($leaseMinutes); + + // Робот мог умереть, не отчитавшись. Без возврата по сроку аренды такое + // задание держало бы очередь навсегда, и все кампании встали бы молча. + RobotJob::where('status', RobotJob::STATUS_TAKEN) + ->where('taken_at', '<', $deadline) + ->update(['status' => RobotJob::STATUS_QUEUED, 'taken_at' => null]); + + if (RobotJob::where('status', RobotJob::STATUS_TAKEN)->exists()) { + return null; + } + + $job = RobotJob::where('status', RobotJob::STATUS_QUEUED) + ->orderBy('id')->lockForUpdate()->first(); + + if ($job === null) { + return null; + } + + $job->update([ + 'status' => RobotJob::STATUS_TAKEN, + 'taken_at' => now(), + 'attempts' => $job->attempts + 1, + ]); + + return $job->refresh(); + }); + } +} +``` + +- [ ] **Шаг 4: Тест зелёный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TelegramRobotQueueTest.php` +Ожидаем: PASS, 4 теста. + +- [ ] **Шаг 5: Коммит** + +```bash +git add app/app/Services/ClientTg/TelegramRobotQueue.php app/tests/Feature/ClientTg/TelegramRobotQueueTest.php +git commit -F - <<'MSG' +feat телеграм-робот: очередь заданий — поставить, выдать по одному, вернуть зависшее + +Выдача строго по одному: у робота один профиль браузера и одна сессия кабинета. +Задание, по которому робот не отчитался за срок аренды, возвращается в очередь. +MSG +``` + +--- + +### Задача 5: Сервис-токен канала + +**Файлы:** + +- Создать: `app/app/Http/Middleware/TgRobotToken.php` +- Изменить: `app/bootstrap/app.php` (алиасы и список исключений CSRF) +- Тест: `app/tests/Feature/ClientTg/TgRobotTokenTest.php` + +- [ ] **Шаг 1: Пишем падающий тест** + +```php +set('services.tg_robot.token', 'секрет'); + $request = Request::create('/api/tg-robot/next'); + $request->headers->set('X-Tg-Robot-Token', 'секрет'); + + $response = (new TgRobotToken)->handle($request, fn () => response('ок')); + + expect($response->getContent())->toBe('ок'); +}); + +it('не пускает с чужим токеном', function () { + config()->set('services.tg_robot.token', 'секрет'); + $request = Request::create('/api/tg-robot/next'); + $request->headers->set('X-Tg-Robot-Token', 'не тот'); + + (new TgRobotToken)->handle($request, fn () => response('ок')); +})->throws(HttpException::class); + +it('канал закрыт, когда токен в настройках пуст', function () { + config()->set('services.tg_robot.token', ''); + $request = Request::create('/api/tg-robot/next'); + $request->headers->set('X-Tg-Robot-Token', ''); + + (new TgRobotToken)->handle($request, fn () => response('ок')); +})->throws(HttpException::class); +``` + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TgRobotTokenTest.php` +Ожидаем: FAIL — класса нет. + +- [ ] **Шаг 3: Пишем посредник** + +```php +header('X-Tg-Robot-Token', ''); + + if ($expected === '' || ! hash_equals($expected, $given)) { + abort(401, 'Неверный сервис-токен телеграм-робота.'); + } + + return $next($request); + } +} +``` + +- [ ] **Шаг 4: Регистрируем алиас и исключаем канал из CSRF** + +В `app/bootstrap/app.php`, рядом со строкой `'creative-robot' => CreativeRobotToken::class,` добавить: + +```php + 'tg-robot' => TgRobotToken::class, +``` + +и в том же файле, в список исключений CSRF рядом с `'api/creative-robot/*',` добавить: + +```php + 'api/tg-robot/*', +``` + +Не забыть `use App\Http\Middleware\TgRobotToken;` в шапке файла. + +- [ ] **Шаг 5: Тест зелёный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TgRobotTokenTest.php` +Ожидаем: PASS, 3 теста. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/app/Http/Middleware/TgRobotToken.php app/bootstrap/app.php app/tests/Feature/ClientTg/TgRobotTokenTest.php +git commit -m "feat телеграм-робот: сервис-токен канала, пустой токен закрывает канал" +``` + +--- + +### Задача 6: Канал — выдать задание + +**Файлы:** + +- Создать: `app/app/Http/Controllers/Api/TgRobotController.php` +- Изменить: `app/routes/web.php` (после группы `api/creative-robot`) +- Тест: `app/tests/Feature/ClientTg/TgRobotNextTest.php` + +- [ ] **Шаг 1: Пишем падающий тест** + +```php +create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_RUNNING, + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '315.00', + 'created_by' => 1, + ]); +} + +beforeEach(function () { + config()->set('services.tg_robot.token', 'секрет'); + $this->tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + $this->campaign = tgNextCampaign($this->tenant->id); +}); + +it('без токена канал не отвечает', function () { + $this->getJson('/api/tg-robot/next')->assertStatus(401); +}); + +it('когда заданий нет — отвечает пустотой, а не ошибкой', function () { + $this->withHeader('X-Tg-Robot-Token', 'секрет') + ->getJson('/api/tg-robot/next') + ->assertOk() + ->assertJson(['job' => null]); +}); + +it('выдаёт задание с составом кампании и адресом файла номеров', function () { + app(TelegramRobotQueue::class)->enqueue( + $this->tenant->id, + $this->campaign->id, + RobotJob::MODE_DRAFT, + ['adText' => 'привет', 'buttonUrl' => 'https://t.me/x', 'budgetRub' => '1000.00'], + ); + + $response = $this->withHeader('X-Tg-Robot-Token', 'секрет')->getJson('/api/tg-robot/next'); + + $response->assertOk() + ->assertJsonPath('job.mode', 'draft') + ->assertJsonPath('job.task.adText', 'привет') + ->assertJsonPath('job.task.buttonUrl', 'https://t.me/x'); + + expect($response->json('job.phones_url'))->toContain('/api/tg-robot/jobs/'); + expect($response->json('job.task'))->not->toHaveKey('phones'); +}); +``` + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TgRobotNextTest.php` +Ожидаем: FAIL — маршрута нет (404). + +- [ ] **Шаг 3: Пишем контроллер (пока только `next`)** + +```php +queue->takeNext(); + + if ($job === null) { + return response()->json(['job' => null]); + } + + return response()->json(['job' => [ + 'id' => $job->id, + 'campaign_id' => $job->campaign_id, + 'mode' => $job->mode, + 'task' => $job->payload, + 'phones_url' => url("/api/tg-robot/jobs/{$job->id}/phones"), + ]]); + } +} +``` + +- [ ] **Шаг 4: Добавляем маршруты** + +В `app/routes/web.php` после группы `api/creative-robot` добавить: + +```php +// Служебный канал «Телеграм-робот кабинета МТС → Портал». Токен вместо пользователя. +// admin-db — робот ходит без tenant-контекста, работает под ролью crm_admin_user. +// Токен ПЕРВЫМ, служебное соединение вторым: сначала пропуск, потом ключи от служебного входа. +Route::middleware(['tg-robot', 'admin-db'])->prefix('api/tg-robot')->group(function () { + Route::get('/next', [TgRobotController::class, 'next']); + Route::get('/jobs/{jobId}/phones', [TgRobotController::class, 'phones'])->whereNumber('jobId'); + Route::post('/jobs/{jobId}/done', [TgRobotController::class, 'done'])->whereNumber('jobId'); +}); +``` + +и в шапке файла — `use App\Http\Controllers\Api\TgRobotController;`. + +- [ ] **Шаг 5: Тест зелёный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TgRobotNextTest.php` +Ожидаем: PASS, 3 теста. + +Маршруты `phones` и `done` уже объявлены, а методов ещё нет — Laravel уронит загрузку +маршрутов. Поэтому в этом же шаге добавляем в контроллер две временные заглушки, они +живут ровно до задач 7 и 8: + +```php + /** Заглушка до задачи 7. */ + public function phones(int $jobId) + { + abort(501, 'Выдача номеров ещё не сделана (задача 7).'); + } + + /** Заглушка до задачи 8. */ + public function done(int $jobId) + { + abort(501, 'Приём отчёта ещё не сделан (задача 8).'); + } +``` + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/app/Http/Controllers/Api/TgRobotController.php app/routes/web.php app/tests/Feature/ClientTg/TgRobotNextTest.php +git commit -m "feat телеграм-робот: канал выдачи задания роботу" +``` + +--- + +### Задача 7: Канал — отдать номера (ПДн) + +**Файлы:** + +- Изменить: `app/app/Http/Controllers/Api/TgRobotController.php` +- Тест: `app/tests/Feature/ClientTg/TgRobotPhonesTest.php` + +- [ ] **Шаг 1: Пишем падающий тест** + +```php +create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_RUNNING, + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '315.00', + 'created_by' => 1, + ]); +} + +beforeEach(function () { + config()->set('services.tg_robot.token', 'секрет'); + $this->tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + $this->campaign = tgPhonesCampaign($this->tenant->id); +}); + +it('отдаёт номера построчно только по заданию в работе', function () { + $job = app(TelegramRobotQueue::class)->enqueue( + $this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, [], + ); + app(TelegramRobotQueue::class)->takeNext(); + + // Подменяем сервис аудитории: здесь проверяется КАНАЛ, а не подбор номеров. + $this->mock(TelegramAudienceService::class, function ($mock) { + $mock->shouldReceive('phonesForCampaign')->andReturn(['79000000001', '79000000002']); + }); + + $this->withHeader('X-Tg-Robot-Token', 'секрет') + ->get("/api/tg-robot/jobs/{$job->id}/phones") + ->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') + ->assertSee('79000000001') + ->assertSee('79000000002'); +}); + +it('не отдаёт номера по заданию, которое не в работе', function () { + $job = app(TelegramRobotQueue::class)->enqueue( + $this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, [], + ); + + $this->withHeader('X-Tg-Robot-Token', 'секрет') + ->get("/api/tg-robot/jobs/{$job->id}/phones") + ->assertStatus(404); +}); +``` + +🪤 Второй тест — **тот самый, который обязан упасть, если убрать `->where('status', TAKEN)`** +из метода. Если он проходит и с вырезанной проверкой — сторож не сторожит, и ПДн отдаются +по любому номеру задания. Проверить это **вырезанием** перед коммитом. + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TgRobotPhonesTest.php` +Ожидаем: FAIL — метода нет / 501. + +- [ ] **Шаг 3: Пишем метод** + +Добавить в `TgRobotController` (и `use` для `RobotJob`, `TelegramAudienceService`, `Response`): + +```php + /** + * Отдать роботу номера кампании — построчно, обычным текстом. + * + * 🔴 ПДн. Отдаём ТОЛЬКО по заданию, которое сейчас в работе, и только по тому номеру + * задания, который робот прислал в адресе. Иначе утёкший токен позволил бы перебором + * номеров вычерпать клиентские базы. Проверка держится на самом запросе, а не на + * внешнем условии «в работе кто-то один». + */ + public function phones(int $jobId, TelegramAudienceService $audience): Response + { + $job = RobotJob::where('id', $jobId) + ->where('status', RobotJob::STATUS_TAKEN) + ->first(); + + abort_if($job === null, 404, 'Задание не в работе — номера по нему не выдаются.'); + + $phones = $audience->phonesForCampaign($job->tenant_id, $job->campaign_id); + + return response(implode("\n", $phones), 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'no-store', + ]); + } +``` + +- [ ] **Шаг 4: Добавляем метод в сервис аудитории** + +В `app/app/Services/ClientTg/TelegramAudienceService.php` добавить публичный метод: + +```php + /** + * Номера кандидатов кампании — для выдачи роботу. + * + * Отдельный вход рядом с build(): каналу робота нужен готовый список, а не пересчёт + * состава кампании. Tenant-контекст ставит вызывающая сторона. + * + * @return list + */ + public function phonesForCampaign(int $tenantId, int $campaignId): array + { + $campaign = \App\Models\ClientTg\Campaign::where('tenant_id', $tenantId) + ->where('id', $campaignId) + ->firstOrFail(); + + return $this->build($campaign)->phones; + } +``` + +- [ ] **Шаг 5: Тест зелёный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TgRobotPhonesTest.php` +Ожидаем: PASS, 2 теста. + +- [ ] **Шаг 6: Коммит** + +```bash +git add app/app/Http/Controllers/Api/TgRobotController.php app/app/Services/ClientTg/TelegramAudienceService.php app/tests/Feature/ClientTg/TgRobotPhonesTest.php +git commit -F - <<'MSG' +feat телеграм-робот: выдача номеров роботу только по заданию в работе + +Номера не кладём в задание и не пишем в журнал — отдельный запрос, без кеша. +MSG +``` + +--- + +### Задача 8: Канал — принять отчёт и применить итог + +**Файлы:** + +- Создать: `app/app/Services/ClientTg/TelegramCampaignResultApplier.php` +- Изменить: `app/app/Http/Controllers/Api/TgRobotController.php` +- Изменить: `app/app/Jobs/ClientTg/RunTelegramCampaignJob.php` (метод `finalize` переезжает в сервис) +- Тест: `app/tests/Feature/ClientTg/TgRobotDoneTest.php` + +- [ ] **Шаг 1: Пишем падающий тест** + +```php +create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_RUNNING, + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '315.00', + 'created_by' => 1, + ]); +} + +beforeEach(function () { + config()->set('services.tg_robot.token', 'секрет'); + config()->set('client_tg.sandbox', true); + $this->tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + $this->campaign = tgDoneCampaign($this->tenant->id); + $this->queue = app(TelegramRobotQueue::class); +}); + +it('принимает успешный отчёт, закрывает задание и двигает кампанию', function () { + $job = $this->queue->enqueue($this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, []); + $this->queue->takeNext(); + + $this->withHeader('X-Tg-Robot-Token', 'секрет') + ->postJson("/api/tg-robot/jobs/{$job->id}/done", [ + 'ok' => true, 'matched' => 400, 'launched' => false, 'campaignId' => '777', + ]) + ->assertOk(); + + expect(RobotJob::find($job->id)->status)->toBe(RobotJob::STATUS_DONE); + expect(Campaign::find($this->campaign->id)->mts_campaign_id)->toBe('777'); + expect(Campaign::find($this->campaign->id)->status)->toBe(Campaign::STATUS_DRAFT_READY); +}); + +it('принимает отказ и помечает задание провалившимся', function () { + $job = $this->queue->enqueue($this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, []); + $this->queue->takeNext(); + + $this->withHeader('X-Tg-Robot-Token', 'секрет') + ->postJson("/api/tg-robot/jobs/{$job->id}/done", [ + 'ok' => false, 'reason' => 'кабинет не открылся', 'step' => 'audience', + ]) + ->assertOk(); + + expect(RobotJob::find($job->id)->status)->toBe(RobotJob::STATUS_FAILED); + expect(Campaign::find($this->campaign->id)->status)->toBe(Campaign::STATUS_FAILED); +}); + +it('не принимает отчёт по заданию, которое не в работе', function () { + $job = $this->queue->enqueue($this->tenant->id, $this->campaign->id, RobotJob::MODE_DRAFT, []); + + $this->withHeader('X-Tg-Robot-Token', 'секрет') + ->postJson("/api/tg-robot/jobs/{$job->id}/done", ['ok' => true]) + ->assertStatus(404); +}); +``` + +🪤 Третий тест — сторож от повторного отчёта. Проверить его **вырезанием**: убрать +`->where('status', TAKEN)` из метода `done`; если тест всё равно зелёный, значит защиты нет +и повторный отчёт второй раз двинет кампанию и деньги. + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TgRobotDoneTest.php` +Ожидаем: FAIL — метода нет / 501. + +- [ ] **Шаг 3: Выносим применение итога из джоба в сервис** + +Создать `app/app/Services/ClientTg/TelegramCampaignResultApplier.php`, перенеся в него **без изменения логики** тело метода `finalize` из `RunTelegramCampaignJob` (строки 129–195). Подпись: + +```php +tenantTx($tenantId, function () use ($tenantId, $campaign, $result, &$failedTenant, &$failedCampaign): void { + /** @var Campaign $fresh */ + $fresh = Campaign::where('tenant_id', $tenantId)->findOrFail($campaign->id); + + // Ранний/надёжный захват id кампании МТС: сохраняем при любом исходе (успех/отказ), + // не затирая уже сохранённый ранее id, если робот на этот раз id не вернул. + if ($result->campaignId !== null && $result->campaignId !== '' && $result->campaignId !== 'unknown') { + $fresh->mts_campaign_id = $result->campaignId; + } + + if (! $result->ok) { + Log::warning('client_tg.robot_failed', [ + 'campaign_id' => $fresh->id, + 'step' => $result->step, + 'reason' => $result->reason, + ]); + $fresh->status_reason = $result->reason; + + // Осторожно с деньгами (ревью-фикс F5, как уборщик, находка #2): есть + // mts_campaign_id (черновик в кабинете реально создан) → кампания МОГЛА + // уйти на модерацию → needs_review БЕЗ возврата брони, ждём ручной сверки + // / опросчика. Без id — заведомо не ушла → failed + возврат брони. + if ($fresh->mts_campaign_id !== null && $fresh->mts_campaign_id !== '') { + $fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW); + Log::warning('client_tg.robot_failed_needs_review', ['campaign_id' => $fresh->id]); + + return; // деньги не трогаем, уведомление об отказе не шлём (не отклонена) + } + + $fresh->transitionTo(Campaign::STATUS_FAILED); + $failedCampaign = $fresh; + $failedTenant = Tenant::find($tenantId); + + return; + } + + $fresh->matched_count = $result->matched; + // launched=true (живой режим) = кампания УШЛА НА МОДЕРАЦИЮ МТС, а не + // «запущена»: ставим `moderating`, одобрение переведёт в `launched` + // опросчик вердикта. Песочница (черновик) → `draft_ready`. + $fresh->transitionTo($result->launched ? Campaign::STATUS_MODERATING : Campaign::STATUS_DRAFT_READY); + }); + + // При отказе робота (заведомо не ушла в кабинет — id нет) возвращаем списанную + // смету на общий баланс, иначе клиент заплатил за несделанную работу. Отдельный + // tenantTx ПОСЛЕ записи статуса — чтобы сбой возврата не откатил FAILED. В + // песочнице денег не списывали — refund просто ничего не найдёт (сальдо 0). + if ($failedCampaign !== null && ! $sandbox) { + try { + $this->tenantTx($tenantId, fn () => app(TelegramCampaignChargeService::class)->refund($failedCampaign)); + } catch (Throwable $e) { + Log::warning('client_tg.refund_failed', [ + 'campaign_id' => $failedCampaign->id, + 'error' => $e->getMessage(), + ]); + } + } + + if ($failedCampaign !== null && $failedTenant !== null) { + try { + app(NotificationService::class)->notifyTelegramCampaignRejected($failedTenant, $failedCampaign); + } catch (Throwable $e) { + // Сбой канала уведомления не должен ронять итог кампании. + Log::warning('client_tg.reject_notify_failed', [ + 'campaign_id' => $failedCampaign->id, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * @template T + * + * @param callable(): T $fn + * @return T + */ + private function tenantTx(int $tenantId, callable $fn) + { + return DB::transaction(function () use ($tenantId, $fn) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + return $fn(); + }); + } +} +``` + +Добавить в шапку файла ещё `use App\Services\ClientTg\TelegramCampaignChargeService;` — он +нужен для возврата сметы. + +> ⚠️ Код выше — построчная копия `finalize()` из джоба (строки 129–207) с единственной +> заменой: `$this->tenantId` → `$tenantId`, а `tenantTx` берёт номер тенанта параметром. +> Никаких «улучшений» по дороге: эта логика уже стоит на бою и трогает деньги. Любая правка — +> отдельным коммитом после того, как оба пути зелёные. + +После переноса `RunTelegramCampaignJob::finalize` заменить на вызов: + +```php + app(TelegramCampaignResultApplier::class)->apply($this->tenantId, $campaign, $result, $sandbox); +``` + +- [ ] **Шаг 4: Пишем метод `done`** + +В шапку контроллера добавить: `use App\Models\ClientTg\Campaign;`, +`use App\Services\ClientTg\RobotResult;`, +`use App\Services\ClientTg\TelegramCampaignResultApplier;`, +`use Illuminate\Http\Request;`. + +```php + /** + * Принять отчёт робота о задании. + * + * Отчёт принимается ТОЛЬКО по заданию в работе: повторный или чужой отчёт по уже + * закрытому заданию не должен второй раз двигать кампанию и трогать деньги. + */ + public function done(int $jobId, Request $request): JsonResponse + { + $job = RobotJob::where('id', $jobId) + ->where('status', RobotJob::STATUS_TAKEN) + ->first(); + + abort_if($job === null, 404, 'Задание не в работе — отчёт по нему не принимается.'); + + /** @var array $payload */ + $payload = $request->all(); + $result = RobotResult::fromRobotJson($payload); + + $campaign = Campaign::where('tenant_id', $job->tenant_id) + ->where('id', $job->campaign_id) + ->firstOrFail(); + + app(TelegramCampaignResultApplier::class)->apply( + $job->tenant_id, + $campaign, + $result, + (bool) config('client_tg.sandbox', true), + ); + + $job->update([ + 'status' => $result->ok ? RobotJob::STATUS_DONE : RobotJob::STATUS_FAILED, + 'result' => $payload, + 'failure_reason' => $result->ok ? null : mb_substr((string) $result->reason, 0, 1024), + 'finished_at' => now(), + ]); + + return response()->json(['accepted' => true]); + } +``` + +- [ ] **Шаг 5: Тест зелёный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/TgRobotDoneTest.php` +Ожидаем: PASS, 3 теста. + +- [ ] **Шаг 6: Прогон всех тестов телеграма — старый путь не сломан** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg` +Ожидаем: PASS, ни одного падения. Если падает что-то из старых тестов — значит перенос `finalize` был не построчным, откатить и перенести заново. + +- [ ] **Шаг 7: Коммит** + +```bash +git add app/app/Services/ClientTg/TelegramCampaignResultApplier.php app/app/Http/Controllers/Api/TgRobotController.php app/app/Jobs/ClientTg/RunTelegramCampaignJob.php app/tests/Feature/ClientTg/TgRobotDoneTest.php +git commit -F - <<'MSG' +feat телеграм-робот: приём отчёта роботом и общее применение итога + +Применение итога вынуто из джоба в сервис без изменения логики — теперь его зовут +оба пути, процессный и опросный. Отчёт принимается только по заданию в работе. +MSG +``` + +--- + +### Задача 9: Развилка в джобе запуска кампании + +**Файлы:** + +- Изменить: `app/app/Jobs/ClientTg/RunTelegramCampaignJob.php:98-127` +- Тест: `app/tests/Feature/ClientTg/RunTelegramCampaignTransportTest.php` + +- [ ] **Шаг 1: Пишем падающий тест** + +```php +create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_QUEUED, + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '315.00', + 'created_by' => 1, + ]); +} + +beforeEach(function () { + config()->set('client_tg.sandbox', true); + $this->tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + $this->campaign = tgTransportCampaign($this->tenant->id); +}); + +it('при опросном канале ставит задание роботу и не запускает процесс', function () { + config()->set('client_tg.robot.transport', 'poll'); + + $runner = $this->mock(TelegramRobotRunner::class, function ($mock) { + $mock->shouldNotReceive('run'); + }); + + (new RunTelegramCampaignJob($this->campaign->id, $this->tenant->id))->handle( + app(TelegramAudienceService::class), + $runner, + ); + + expect(RobotJob::where('campaign_id', $this->campaign->id)->count())->toBe(1); + expect(Campaign::find($this->campaign->id)->status)->toBe(Campaign::STATUS_RUNNING); +}); + +it('при процессном канале по-прежнему запускает робота сам', function () { + config()->set('client_tg.robot.transport', 'process'); + + $runner = $this->mock(TelegramRobotRunner::class, function ($mock) { + $mock->shouldReceive('run')->once()->andReturn(RobotResult::failed('стенд', 'test')); + }); + + (new RunTelegramCampaignJob($this->campaign->id, $this->tenant->id))->handle( + app(TelegramAudienceService::class), + $runner, + ); + + expect(RobotJob::where('campaign_id', $this->campaign->id)->count())->toBe(0); +}); +``` + +🪤 Оба теста опираются на то, что `TelegramAudienceService::build()` соберёт непустой состав +для кампании с `audience_kind = AUDIENCE_LIST` и `planned_count = 2`. Если на пустой базе он +вернёт ноль кандидатов и джоб выйдет раньше развилки — добавить в `beforeEach` создание двух +сделок-кандидатов тем же способом, что делает `AudienceServiceTest.php`, и **сначала убедиться +прогоном**, что состав непустой. Гадать тут нельзя: молчаливый выход из джоба даст зелёный +тест при неработающем коде. + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd app && ./vendor/bin/pest tests/Feature/ClientTg/RunTelegramCampaignTransportTest.php` +Ожидаем: FAIL — при `poll` задание не создаётся. + +- [ ] **Шаг 3: Правим джоб** + +Заменить блок с `$phonesFile` (строки 100–126) на: + +```php + // Опросный канал: робот живёт на ДРУГОЙ машине (МТС не пускает адреса дата-центров), + // запустить его отсюда физически нельзя. Кладём задание и выходим — итог придёт + // отдельным запросом в /api/tg-robot/jobs/{id}/done. Номера в задание НЕ кладём (ПДн): + // робот заберёт их отдельным запросом, пока задание в работе. + if (config('client_tg.robot.transport') === 'poll') { + $this->tenantTx(fn () => app(TelegramRobotQueue::class)->enqueue( + tenantId: $this->tenantId, + campaignId: $campaign->id, + mode: $sandbox ? RobotJob::MODE_DRAFT : RobotJob::MODE_LIVE, + payload: [ + 'adText' => $campaign->ad_text, + 'buttonUrl' => $campaign->ad_link, + 'budgetRub' => (string) $campaign->budget_cap_rub, + 'ordCategory' => $campaign->ord_category, + 'mediaFile' => $campaign->media_path, + 'moderatorFile' => $campaign->moderator_file_path, + 'clientTag' => 'tg:'.$campaign->id, + ], + )); + + Log::info('client_tg.robot_job_enqueued', [ + 'campaign_id' => $campaign->id, + 'tenant_id' => $this->tenantId, + ]); + + return; + } + + // Файл номеров для робота (ПДн) — временный, чистим в finally. + $phonesFile = tempnam(sys_get_temp_dir(), 'tg-phones-'); + + try { + file_put_contents($phonesFile, implode("\n", $candidates)); + + $result = $runner->run([ + 'mode' => $sandbox ? 'draft' : 'live', + 'phonesFile' => $phonesFile, + 'adText' => $campaign->ad_text, + 'buttonUrl' => $campaign->ad_link, + 'budgetRub' => (string) $campaign->budget_cap_rub, + 'ordCategory' => $campaign->ord_category, + 'mediaFile' => $campaign->media_path, + 'moderatorFile' => $campaign->moderator_file_path, + 'clientTag' => 'tg:'.$campaign->id, + ]); + + app(TelegramCampaignResultApplier::class)->apply($this->tenantId, $campaign, $result, $sandbox); + } finally { + if (is_string($phonesFile) && file_exists($phonesFile)) { + @unlink($phonesFile); + } + } +``` + +Добавить в шапку: `use App\Models\ClientTg\RobotJob;`, `use App\Services\ClientTg\TelegramRobotQueue;`, `use App\Services\ClientTg\TelegramCampaignResultApplier;`. + +- [ ] **Шаг 4: Тест зелёный + весь телеграм** + +Запустить: + +```bash +cd app && ./vendor/bin/pest tests/Feature/ClientTg/RunTelegramCampaignTransportTest.php +./vendor/bin/pest tests/Feature/ClientTg +``` + +Ожидаем: PASS оба раза. + +- [ ] **Шаг 5: Коммит** + +```bash +git add app/app/Jobs/ClientTg/RunTelegramCampaignJob.php app/tests/Feature/ClientTg/RunTelegramCampaignTransportTest.php +git commit -F - <<'MSG' +feat телеграм-робот: джоб запуска умеет опросный канал + +При transport=poll портал кладёт задание и выходит, робот заберёт его сам. +Процессный путь оставлен рабочим и остаётся умолчанием. +MSG +``` + +--- + +### Задача 10: Сторона робота — разговор с порталом + +**Файлы:** + +- Создать: `bots/mts-telegram-ads/src/portal.js` +- Тест: `bots/mts-telegram-ads/test/portal.test.js` + +- [ ] **Шаг 1: Пишем падающий тест** + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { portalClient } from '../src/portal.js'; + +test('portal: берёт задание и подставляет токен в заголовок', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ job: { id: 7, mode: 'draft' } }) }; + }; + const portal = portalClient({ baseUrl: 'https://portal', token: 'секрет', fetchImpl: fetchStub }); + + const job = await portal.next(); + + assert.equal(job.id, 7); + assert.equal(calls[0].url, 'https://portal/api/tg-robot/next'); + assert.equal(calls[0].opts.headers['X-Tg-Robot-Token'], 'секрет'); +}); + +test('portal: заданий нет — возвращает null, а не падает', async () => { + const fetchStub = async () => ({ ok: true, status: 200, json: async () => ({ job: null }) }); + const portal = portalClient({ baseUrl: 'https://portal', token: 'с', fetchImpl: fetchStub }); + assert.equal(await portal.next(), null); +}); + +test('portal: чужой токен — понятная ошибка, а не молчание', async () => { + const fetchStub = async () => ({ ok: false, status: 401, text: async () => 'нет' }); + const portal = portalClient({ baseUrl: 'https://portal', token: 'с', fetchImpl: fetchStub }); + await assert.rejects(() => portal.next(), /401/); +}); + +test('portal: номера приходят построчно и чистятся от пустых строк', async () => { + const fetchStub = async () => ({ ok: true, status: 200, text: async () => '79000000001\n79000000002\n\n' }); + const portal = portalClient({ baseUrl: 'https://portal', token: 'с', fetchImpl: fetchStub }); + assert.deepEqual(await portal.phones('https://portal/api/tg-robot/jobs/7/phones'), ['79000000001', '79000000002']); +}); + +test('portal: отчёт уходит POST-ом с телом задания', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ accepted: true }) }; + }; + const portal = portalClient({ baseUrl: 'https://portal', token: 'с', fetchImpl: fetchStub }); + + await portal.done(7, { ok: true, matched: 400 }); + + assert.equal(calls[0].url, 'https://portal/api/tg-robot/jobs/7/done'); + assert.equal(calls[0].opts.method, 'POST'); + assert.deepEqual(JSON.parse(calls[0].opts.body), { ok: true, matched: 400 }); +}); +``` + +- [ ] **Шаг 2: Убеждаемся, что тест красный** + +Запустить: `cd bots/mts-telegram-ads && npm test` +Ожидаем: FAIL — модуля `src/portal.js` нет. + +- [ ] **Шаг 3: Пишем модуль** + +```js +// Разговор робота с порталом: взять задание, забрать номера, отчитаться. +// fetchImpl подменяется в тестах — сеть в тестах не трогаем. + +export function portalClient({ baseUrl, token, fetchImpl = fetch }) { + const headers = { 'X-Tg-Robot-Token': token, Accept: 'application/json' }; + + async function ensureOk(res, what) { + if (!res.ok) { + const body = res.text ? await res.text().catch(() => '') : ''; + throw new Error(`Портал ответил ${res.status} на ${what}: ${String(body).slice(0, 200)}`); + } + return res; + } + + return { + // Очередное задание либо null, если работы нет. + async next() { + const res = await ensureOk(await fetchImpl(`${baseUrl}/api/tg-robot/next`, { headers }), 'выдачу задания'); + const data = await res.json(); + return data.job ?? null; + }, + + // Номера кампании построчно. Пустые строки выбрасываем — иначе кабинет МТС + // посчитает их за номера и отобьёт файл целиком. + async phones(url) { + const res = await ensureOk(await fetchImpl(url, { headers }), 'выдачу номеров'); + const text = await res.text(); + return text.split('\n').map((s) => s.trim()).filter((s) => s !== ''); + }, + + // Отчёт о задании. Тело — ровно то, что печатает runner.js. + async done(jobId, result) { + const res = await fetchImpl(`${baseUrl}/api/tg-robot/jobs/${jobId}/done`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(result), + }); + await ensureOk(res, 'приём отчёта'); + return res.json(); + }, + }; +} +``` + +- [ ] **Шаг 4: Тест зелёный** + +Запустить: `cd bots/mts-telegram-ads && npm test` +Ожидаем: PASS, 113 прежних + 5 новых = 118. + +- [ ] **Шаг 5: Коммит** + +```bash +git add bots/mts-telegram-ads/src/portal.js bots/mts-telegram-ads/test/portal.test.js +git commit -m "feat телеграм-робот: разговор робота с порталом — задание, номера, отчёт" +``` + +--- + +### Задача 11: Сторона робота — один проход опроса + +**Файлы:** + +- Создать: `bots/mts-telegram-ads/bin/poll.js` +- Изменить: `bots/mts-telegram-ads/.env.example` +- Изменить: `bots/mts-telegram-ads/README.md` (раздел «Как поселить робота») + +- [ ] **Шаг 1: Пишем проход** + +```js +import 'dotenv/config'; +import { writeFileSync, unlinkSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadConfig } from '../src/config.js'; +import { parseTask } from '../src/task.js'; +import { createMailer, smtpTransport } from '../src/mailer.js'; +import { runTask } from '../src/runner.js'; +import { portalClient } from '../src/portal.js'; + +// Один проход: спросить портал, есть ли работа; если есть — сделать и отчитаться. +// Запускается таймером раз в минуту. Своей петли внутри НЕТ — так проще убить и +// перезапустить, и один зависший проход не мешает следующему (портал вернёт задание +// в очередь по сроку аренды). + +const baseUrl = process.env.PORTAL_BASE_URL; +const token = process.env.TG_ROBOT_TOKEN; +if (!baseUrl || !token) { + console.error('Не заданы PORTAL_BASE_URL и/или TG_ROBOT_TOKEN'); + process.exit(2); +} + +const portal = portalClient({ baseUrl, token }); +const job = await portal.next(); + +if (!job) { + console.log('Работы нет.'); + process.exit(0); +} + +console.log(`Задание ${job.id}, режим ${job.mode}`); + +const config = loadConfig(); +const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); +const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + +// Номера — ПДн: кладём во временный файл и убираем в finally при любом исходе. +const phonesFile = join(tmpdir(), `tg-phones-${job.id}-${process.pid}.txt`); +let result; + +try { + const phones = await portal.phones(job.phones_url); + writeFileSync(phonesFile, phones.join('\n'), 'utf8'); + + const task = parseTask({ ...job.task, mode: job.mode, phonesFile }); + result = await runTask(config, task, mailer, { timestamp }); +} catch (e) { + result = { ok: false, reason: String(e.message).slice(0, 255), step: 'poll' }; +} finally { + if (existsSync(phonesFile)) { + try { unlinkSync(phonesFile); } catch { /* файл уже убран */ } + } +} + +await portal.done(job.id, result); +console.log(`Отчитались по заданию ${job.id}: ok=${result.ok}`); +process.exit(result.ok ? 0 : 1); +``` + +- [ ] **Шаг 2: Проверяем, что проход честно молчит без работы** + +Поднять портал локально (`cd app && php artisan serve`), выставить в нём `TG_ROBOT_TOKEN=проба`, затем: + +```bash +cd bots/mts-telegram-ads +PORTAL_BASE_URL=http://127.0.0.1:8000 TG_ROBOT_TOKEN=проба node bin/poll.js +``` + +Ожидаем: `Работы нет.` и код выхода 0. + +- [ ] **Шаг 3: Проверяем, что чужой токен даёт понятную ошибку** + +```bash +PORTAL_BASE_URL=http://127.0.0.1:8000 TG_ROBOT_TOKEN=не-тот node bin/poll.js +``` + +Ожидаем: сообщение с `401`, код выхода не 0. + +- [ ] **Шаг 4: Дописываем настройки в `.env.example`** + +``` +# Портал, у которого робот спрашивает работу (опросный канал). +PORTAL_BASE_URL=https://liderra.ru +TG_ROBOT_TOKEN= +``` + +- [ ] **Шаг 5: Коммит** + +```bash +git add bots/mts-telegram-ads/bin/poll.js bots/mts-telegram-ads/.env.example bots/mts-telegram-ads/README.md +git commit -F - <<'MSG' +feat телеграм-робот: проход опроса — сам спрашивает работу у портала + +Своей петли нет: запускается таймером, зависший проход не мешает следующему. +Номера кладутся во временный файл и убираются при любом исходе. +MSG +``` + +--- + +### Задача 12: Поселить робота там, где решит владелец + +**Файлы:** + +- Создать: `docs/superpowers/2026-07-31-tg-robot-ustanovka.md` (памятка установки) + +> 🔴 Эта задача **выполняется только после явного «да» владельца** — она ставит службу на +> живую машину и трогает боевой портал. Куда именно селить — решение владельца, оно зависит +> от того, чем кончится разговор с поставщиком прокси (обращение 31074). + +- [ ] **Шаг 1: Написать памятку установки** + +В памятке должно быть, слово в слово исполнимо: + +1. Куда класть робота: `/opt/liderra-mts-telegram-robot` на Linux либо `C:\liderra\mts-telegram-robot` на машине владельца. +2. Что положить в `.env` робота: `PORTAL_BASE_URL`, `TG_ROBOT_TOKEN`, `MTS_BROWSER_PROFILE_DIR`, `MTS_CABINET_URL`, `MTS_TELEGRAM_URL`, почта для тревоги, `BUDGET_CAP_RUB`. +3. Тот же `TG_ROBOT_TOKEN` — в `.env` портала на бою. +4. Разовый вход в кабинет глазами: на Linux через виртуальный экран (`ekran-vkl.sh` рядом с роботом креативов на `51.250.1.97`, доступ по SSH-туннелю, наружу закрыт), на Windows — просто `npm run login`. +5. Служба и таймер — списать с `/etc/systemd/system/liderra-creative-robot-run.{service,timer}`, поменяв путь и описание; на Windows — «Планировщик заданий», запуск раз в минуту. +6. Проверка живьём: поставить кампанию в песочнице и **посмотреть глазами в кабинете МТС**, что черновик появился. Наш журнал говорит, что портал ПОДУМАЛ, а не что было. + +- [ ] **Шаг 2: Коммит** + +```bash +git add docs/superpowers/2026-07-31-tg-robot-ustanovka.md +git commit -m "docs телеграм-робот: памятка установки робота на машину" +``` + +--- + +## Порядок выката на бой + +1. Прогнать полный набор тестов: `cd app && ./vendor/bin/pest` — должно быть зелено целиком. +2. Позвать агента `prod-deploy-validator` — вердикт ГОДЕН/НЕГОДЕН. +3. Миграция на бою — ролью `crm_migrator`, сперва `--pretend`. +4. 🔴 **Перезапустить `db/03_service_bypass_policies.sql`** — иначе канал робота увидит ноль строк молча. +5. Прописать `TG_ROBOT_TOKEN` в `.env` портала, `php artisan config:clear` **от `www-data`, не от root** (портал уже ложился на этом на 18 минут). +6. Переключить `TG_ROBOT_TRANSPORT=poll` **последним шагом**, когда робот уже стоит и отвечает. +7. Откат — вернуть `TG_ROBOT_TRANSPORT=process`. Таблица и канал при этом остаются, вреда от них нет. + +## Чего этот план сознательно НЕ делает + +- Не переводит на опрос режимы `read-status` и `resubmit` — отдельный план. +- Не решает вопрос с адресом, с которого робот ходит в МТС: это деньги и решение владельца. +- Не ставит службу автоматически — только по явному «да».