Commit Graph

44 Commits

Author SHA1 Message Date
Дмитрий b7bfe5a46f fix реклама Яндекса: три поломки модерации, вскрытые заходом в живой кабинет
Кампания 713175197 третьи сутки не показывалась при замороженных у клиента
деньгах, а программный интерфейс на всех уровнях рапортовал «Идут показы».
Правду отдала только подпись на экране кабинета: «Для показа в заданных
регионах предоставьте документы» — 13 объявлений придержаны, 2 отклонены.

1. Яндекс отвечает машине ПО-АНГЛИЙСКИ, если не попросить русский.
   Замер боевым ключом, два одинаковых запроса подряд: без заголовка
   «Rejected at moderation.», с Accept-Language ru «Отклонено на модерации.».
   Английская строка уезжала клиенту в переписку и письмом как пояснение
   модератора, честная заглушка «причину выясняем» не срабатывала никогда,
   а на следующем обходе английская строка ложилась ПОВЕРХ доклада разведчика
   — проверено по боевой ленте: 30.07 в 15:02 робот принёс полную причину,
   в 17:00 её накрыло. Лечение: спрашиваем язык явно плюс второй заслон —
   английские отписки узнаются как отписки.

2. Отказ включения Яндекс кладёт в Warnings, а код смотрел только в Errors.
   Ответ целиком: ResumeResults с Warnings 10201 «Объявление не остановлено»
   при пустом Errors. Исключения нет, портал считал, что справился: двое суток
   обход каждые два часа поднимал 13 объявлений, ноль записей в журнале.
   Лечение: resumeAds возвращает, кого включить не дали.

3. Новый исход «принято, но придержано» порталу не был известен вовсе.
   Вердикт ACCEPTED, ярлыка «Отклонено» нет, разведчик не ходил — клиент видел
   «Идут показы» при нулевых показах. Теперь по отказу включения клиенту идёт
   сообщение «Яндекс принял объявление, но пока не показывает его. Причину
   выясняем» — текст выбран владельцем — и туда же едет разведка.

Ловушка, обойденная по дороге: включение спрашиваем ДО разбора объявлений.
Наоборот — сообщение о придержке легло бы поверх доклада разведчика, и обход
начал бы чередовать их по кругу: защита от дублей смотрит на последнее сообщение.

Замеры. Восемь новых сторожей, все написаны ДО починки и падали именно
на живых данных. Отдельный сторож на противоположный случай — включённому
объявлению разведку не заводим — был зелёным с самого начала.
Портал 4083 теста, 4043 прошло, 16 упало: те же шесть давних классов и ровно
те же числа, что до работы, было 4069/4029/16. Плюс 14 тестов — ровно новые.
Pint и Larastan по изменённым файлам чистые.

Главный урок записан в cabinet-flow.md §7.9: в §7.7 лежит правдивый РУССКИЙ
ответ Яндекса, снятый ДРУГИМ инструментом, который язык просил. Разбор отписок
построили по показаниям прибора, которым продукт не пользуется. Мерить надо той
же дорогой, по которой ходит боевой код.

На боевой НЕ выкатывалось.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:12:40 +03:00
Дмитрий 32df332901 fix(телеграм-реклама): заголовок объявления для рекламы сайта — кампания больше не встаёт
Приёмка глазами вскрыла: кабинет МТС требует «Заголовок объявления» (до 40 знаков),
когда в объявлении ссылка на САЙТ, а не на телеграм-канал. Робот про это поле не знал,
«Продолжить» молча не срабатывало, кампания вставала на шаге «Объявление» — в бою уже
ПОСЛЕ списания денег. Проверено живьём: 2234454 (сайт — встала) против 2234462 (канал —
дошла до подтверждения) и 2234490 (сайт с заголовком — дошла).

Портал спрашивает заголовок заранее, на создании черновика: обязателен только для
не-телеграмной ссылки (App\Support\TelegramLink), колонка ad_headline varchar(40),
поле на экране появляется по той же развилке. Робот заполняет его в кабинете.

Три ловушки, добытые живыми прогонами (описаны в коде):
- поле дорисовывается в ОТВЕТ на ссылку, с задержкой — надо ждать, а не спрашивать;
- под описание подходит несколько элементов — нужен .first();
- серая надпись внутри поля НЕ placeholder, а нарисованная подпись: поиск по атрибуту
  давал ноль совпадений при видимом на снимке поле. Опознаём по видимой надписи.

Тесты: робот 130/130, ClientTg 250/250, экран 23/23. Полный прогон бэкенда — те же
13 падающих классов до и после правки (ни одного в телеграм-части). В baseline
статанализа добавлен известный ложный класс Pest для нового файла тестов.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:42:46 +03:00
Дмитрий e724311e38 feat телеграм-робот: чтение вердикта и пересдача переведены на опрос
Теперь на опрос переведены все три работы робота, а не только запуск кампании.
Портал кладёт задание в таблицу, робот сам приходит за ним и отчитывается.
Это нужно потому, что робот живёт не на машине очереди - МТС не пускает адреса
дата-центров.

Что сделано по плану docs/superpowers/plans/2026-07-31-tg-poll-read-status-resubmit.md:

- два новых режима задания: чтение вердикта модерации и пересдача;
- применение вердикта вынуто из джоба в TelegramModerationVerdictApplier,
  применение итога пересдачи - в TelegramResubmitResultApplier; оба переноса
  построчные, денежная логика не менялась ни в одном символе;
- у обоих джобов появилась развилка по каналу: на опросе задание ставится,
  робот не запускается;
- приёмщик отчёта различает режимы - иначе отчёт о чтении вердикта применился
  бы как отчёт о запуске и сдвинул кампанию не туда;
- номера телефонов больше не выдаются режимам, которым они не нужны: чтению
  вердикта и пересдаче аудитория не требуется, она у кампании уже есть;
- у робота развилка по режиму вынесена в отдельный src/poll-plan.js, чтобы
  её можно было проверять без браузера и без сети;
- сторож прав на бою: роль портала обязана иметь право ставить задания.

Новых миграций и новых прав НЕ понадобилось: все три места ставят задание под
подключением по умолчанию, то есть под ролью портала, у которой права уже есть.
Схема БД не менялась, запись в CHANGELOG не требуется.

Приёмка вырезанием: убираем ограничение по режиму в выдаче номеров - два теста
падают, возвращаем - зелёные.

Проверено:
  телеграм на портале  244 из 244 (было 219, старые тесты в том числе)
  робот                124 из 124 (было 120)
  статанализ           0 настоящих замечаний
  полный прогон        4039 тестов, 3995 прошло, 20 упало

Из 20 падений 19 - те же давние, что были до работы. Двадцатое -
ExternalServiceDownAlertTest, в одиночку проходит 3 из 3 и вместе с телеграм-
тестами тоже; падает только в полном прогоне от накопленных данных. Это
известная слабость: у большинства файлов нет изоляции между тестами.

Заодно сборка тестовой БД переведена с migrate:fresh на связку
db:wipe --drop-types + migrate: первая спотыкалась на призрачном типе
legal_entities, вторая на тех же состояниях отрабатывала без отказов.

На бой не выкачено, переключатель TG_ROBOT_TRANSPORT остаётся в process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:03:45 +03:00
Дмитрий ae3905d725 test телеграм-робот: сторож на латинский токен + перенос защиты в общий вход
Защита от русских букв в токене переехала из bin/poll.js в portalClient — теперь
она стоит на ЛЮБОМ входе, а не только у прохода опроса. Прикрыта двумя тестами
и принята вырезанием: без проверки тесты падают.

Заодно в старых тестах русские токены заменены на латинские: русский токен в
заголовке HTTP не работает в принципе, и тесты закрепляли невозможное.

Тесты робота: было 118, стало 120.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:02:02 +03:00
Дмитрий 9100e1fdda feat телеграм-робот: проход опроса — сам спрашивает работу у портала
Своей петли нет: запускается таймером, зависший проход не мешает следующему.
Номера кладутся во временный файл и убираются при любом исходе.

Проверено живьём против поднятого портала: верный токен — «Работы нет.» и код 0,
чужой токен — понятная ошибка 401.

Заодно закрыта мина: токен уходит в заголовок HTTP, куда пускают только латиницу.
С русскими буквами робот падал нечитаемым сбоем Node ещё до обращения к порталу —
теперь говорит по-человечески. Наступил на неё сам, проверяя план дословно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:22:26 +03:00
Дмитрий 79c51b4399 feat телеграм-робот: разговор робота с порталом — задание, номера, отчёт
Тесты робота: было 113, стало 118.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:15 +03:00
Дмитрий b950cf13d2 fix телеграм-робот: браузер выбирается по машине — Edge на Windows, Chrome на сервере
Было зашито channel msedge. На сервере Edge не ставится, робот там не запускался вообще.
Теперь выбор в config: на Windows msedge, иначе chrome, перебивается MTS_BROWSER_CHANNEL.
Три новых теста на выбор по умолчанию и на ручную перебивку.

Заодно в промт смены записаны живые замеры 30-31.07:
- рендер-сервер 51.250.1.97 настоящим Chrome получает от МТС отказ по адресу, 4 прогона;
  та же проба с рабочей машины 77.74.123.226 даёт форму входа. Прежний вывод «тупик снят»
  был сделан по curl, а curl получает от защиты МТС заглушку и врёт про доступ.
- прокси Proxy.Market сам отвечает 403 на стадии CONNECT для mts.ru, beeline.ru,
  megafon.ru, tele2.ru, при этом yandex.ru, vk.com, avito.ru, rt.ru пропускает.
  Значит режет поставщик прокси, а не сайт. Замер отправлен им, обращение 31074.
- связка портал-робот запускает робота как процесс на своей же машине, поэтому при любом
  решении по адресу её придётся переделать на опрос портала роботом.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:11:03 +03:00
Дмитрий f2bfe3f1da fix реклама Яндекса: пять поломок, которые вскрыл первый живой запуск связки портал-робот-кабинет
Ни одну нельзя было увидеть без настоящего прогона: часть закрыта песочницей Яндекса,
часть — швами между кусками, которые по отдельности покрыты тестами.

1. Слепок креативов уходил с SelectionCriteria пустым МАССИВОМ. Живой Яндекс отвечает
   отказом 8000 «SelectionCriteria cannot contain an array», портал не может снять слепок,
   задание роботу не выдаётся, клиент видит вечное «готовим картинки». В JSON нужен пустой
   ОБЪЕКТ. Песочница до этой проверки не доходила — отвергала запрос раньше, на входе.

2. Отказы Яндекса ПО ПОЗИЦИИ внутри ответа глотались молча. Запрос успешен, а нужного Id
   в позиции нет — вместо него Errors с человеческим объяснением. Портал падал ошибкой PHP
   «Undefined array key Id», ни клиенту, ни в журнал не попадало ни слова из ответа Яндекса.
   Теперь слова Яндекса выходят наружу — именно это и позволило найти пункт 3.

3. В условии ретаргетинга не передавался MembershipLifeSpan — срок хранения человека
   в сегменте. Живой Яндекс отказывает «Required field: Not specified time for goal or
   segment», и следом «Object not found»: довод негоден целиком, запуск встаёт. Берём
   audience_days кампании, границы Яндекса 1..540.

4. Робот не отдавал набор Яндексу: заливал файлы, нажимал «Создать» и уходил. Оказалось,
   «набор» в кабинете и «креатив» в creatives.get — разные вещи: пока набор не отмечен
   галочкой и не нажато «Добавить выбранные», Яндекс креативы не регистрирует. Замер:
   14 залитых картинок были невидимы для creatives.get, после добавления в ЧЕРНОВИК формы
   счётчик прыгнул 18 -> 32 в ту же секунду. Объявление при этом не сохраняется,
   «Сохранить изменения» робот по-прежнему не трогает никогда.

5. Гонка при заливке. Три живых прогона подряд из 15 картинок доносили 14, и каждый раз
   пропадала ДРУГАЯ: 480x320, потом 300x600, потом 336x280. Замер объяснил: кнопка
   «Создать» разблокируется на 4-й секунде, когда принято 11 файлов из 15, остальные
   дозагружаются к 7-й. Робот жал сразу по разблокировке. Ждём теперь по строкам принятых
   файлов и по исчезновению слова «Загружается».

   Первая попытка этой починки НЕ РАБОТАЛА и выглядела рабочей: сторож считал размеры
   в окне, не заметив постоянного фильтра из тех же 15 размеров вверху. Поймано по тому,
   что прогон занял ровно столько же секунд, сколько до починки. Отсюда тире в признаке
   приёма — фильтр его не содержит.

Каждая починка закрыта тестом, который сначала падал на своей поломке. Прежние тесты
проверяли разбор ОТВЕТОВ и путь ДО «Создать» — форму запросов и то, что после, не смотрел
никто. Робот 84 из 84, реклама портала 42 из 42.

Проверено живьём на боевом: задание роботу отработало со статусом done, опознано
15 креативов из 15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 07:14:33 +03:00
Дмитрий 61ac314d59 fix робот-креативов: замок не снимался после удачного прохода, робот работал раз в полчаса
process.exit стоял внутри try, а снятие замка — в finally. Выход обрывает процесс
немедленно, и до finally дело не доходило никогда. Замок оставался лежать после каждого
удачного прохода, и следующие полчаса и run.js, и keepalive.js молча уходили со словами
«робот уже работает»: задание клиента ждало в очереди, а в журнале при этом всё
выглядело благополучно. Молчаливый сбой того же класса — «успех» без работы.

Ни один из 80 тестов этого не видел: проверялись модуль замка и проход по отдельности,
а дыра была ровно в шве между ними.

Новый тест bin-lock.test.js запускает bin/run.js настоящим процессом ДВАЖДЫ подряд
против портала-обманки: одиночный запуск эту дыру не показывает. Тест сначала упал
на обоих утверждениях, после починки зелёный. Было 80 тестов, стало 82.

Проверено живьём на рендер-виртуалке: после прохода за заданием и после захода
в кабинет замок снят.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:56:35 +03:00
Дмитрий 77f61fb1d1 merge: сведение ветки «Реклама Телеграм» с боевым main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Слияние feat/client-telegram-ads с main 8bdd58e8. Десять швов разобраны вручную:
денежный файл AdWalletService взят из main целиком — проверено поимённо, что все три
починки на месте: свой контекст клиента, оживление брони, таяние заморозки. В расписании
объединены оба набора заданий: телеграмные два и рекламные четыре. В боковом меню и в
мобильном «Ещё» сохранён пункт «Рекламный кошелёк», подписи поправлены — на реальные
экраны ведут ОБА канала. Словарь, пример настроек и журнал схемы объединены.

Сверх самого слияния:

- Журнал схемы: телеграмные записи v8.86-v8.95 перенумерованы в v9.18-v9.27, блок
  переставлен наверх, пометки «номер предварительный» сняты и заменены одной врезкой
  о перенумерации. Задвоенных номеров не осталось. Врезка шапки теперь называет и
  телеграмные таблицы: их DDL, как и рекламный, живёт только в дельта-миграциях.
- Новый сторож денег tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php: списание и
  возврат под боевой ролью crm_app_user. С контекстом клиента деньги двигаются, без
  контекста возврат падает громко. Обычные тесты ходят суперюзером и этот класс дыр
  увидеть не могут.
- Помощник rejectedCampaign переименован в tgRejectedCampaign: одноимённый помощник
  есть у рекламного модуля, помощники Pest глобальные, полный прогон падал фаталом.
  Каждая ветка по отдельности этого увидеть не могла.
- Два теста уведомлений считали ВСЮ таблицу целиком вместо строк своего пользователя:
  в одиночку зелёные, в полном прогоне красные. Счёт сужен до конкретного пользователя.
- Убраны две проверки отменённой сущности «своё имя отправителя» — сама сущность
  дропнута в v9.27 как СМС-фантазия, её адрес отдаёт 405.

Прогоны: телеграм 193/193, реклама 336/336 при 1029 проверках, вместе 532/532,
экраны 1704/1708, сборка фронта чисто, полный Unit+Feature 3923/3960. Шестнадцать
падений полного прогона совпадают построчно с прогоном ветки без телеграма — слияние
не добавило ни одного. Статанализ в свежем каталоге запустить не удалось: он требует
сгенерированного файла-подсказки, которого нет в репозитории, и без него молча падает
на обеих ветках.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:41:38 +03:00
Дмитрий 08640c020e fix реклама за показы: живой прогон разведки вскрыл две поломки — робот считал вместо того, чтобы ждать
Разведку впервые прогнали живьём, с разрешения владельца: вызвали саму функцию робота
против боевого кабинета на двух отклонённых объявлениях кампании-пустышки. Живую кампанию
не трогали, робот только читал. Прогон окупился сразу — вскрылись две поломки, которых
не видел ни один из 74 зелёных тестов.

Первая. Робот не нашёл объявление, которое в кабинете было: провал за три секунды,
«объявления в списке нет». Замерили — список рисуется две секунды, а робот считал ячейки
сразу после человекоподобной паузы в 800 миллисекунд. Счёт не ждёт, он отвечает про
«прямо сейчас». На бою это значило бы, что КАЖДЫЙ отказ уезжает в «ждёт разбора»,
а клиент причину не узнаёт никогда.

Вторая. После первой починки робот стал находить объявление и приносить 29 знаков —
один заголовок «Модератор отклонил объявление», без причины. Та же ошибка: строка причины
появляется позже окна, робот считал её и получал ноль, раскрывать было нечего. Клиенту
уехало бы сообщение от Яндекса, в котором нет ни слова о том, что чинить.

Текст окна нарастает по частям: 29 знаков, потом 82, потом 785. Окно не отдаёт ошибку —
честно показывает то, что успело нарисоваться. Поэтому промах молчаливый: робот считал бы,
что справился.

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

Оба сторожа написаны ДО починки и падали с теми самыми живыми ошибками.

Хвост доклада: по решению владельца срезаются подписи кнопок кабинета «Написать в чат»
и «Написать письмо» — у клиента этих кнопок нет, а выглядят они приглашением написать
Яндексу. Режем только хвост и только точное совпадение строки: те же слова внутри
пояснения это слова Яндекса, их не трогаем.

Живая проверка обрезки вскрыла третью ловушку: «Написать письмо» срезалось, а «Написать
в чат» оставалось. Яндекс ставит между короткими словами неразрывный пробел. Глазу он
неотличим от обычного, а сравнению это совсем другой символ. Правило для этого кабинета:
сравнивать текст только по человеческому виду строки, а элементы ждать, а не считать.

Замеры записаны в разметку кабинета, раздел 7.8.

Итог живой проверки с продовой паузой: причины приезжают целиком, кнопок в них нет —
760 знаков по финансовым услугам и 589 по медицине, шесть-семь секунд на объявление.
Снимок берётся только с окна, логин и остаток счёта в кадр не попадают.

Робот 80 из 80. Портал не тронут. На боевой не выкатывалось, рубильник Директа выключен.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:49:34 +03:00
Дмитрий 3fdcd88ad3 feat реклама за показы: правда про документ, экран «ждёт разбора» и разбор своих ошибок
Задача 16 закрыта решением владельца. Дороги «робот везёт документ в кабинет Яндекса»
не существует — доказано двумя нарочными отказами, обычной тематикой и лицензируемой:
в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета.

Приём документа оставлен, но портал больше не молчит: клиент приложил файл — в ленту сразу
ложится отметка «документ у нас, передать его Яндексу автоматически нельзя, при
необходимости отнесём сами и напишем здесь», а владельцу уходит письмо на адрес алертов.
Без письма обещание было бы пустым: файл просто лёг бы на диск. Сам файл письмом
не отправляем — это чужие бумаги. Обычный ответ без файла ни отметки, ни письма не даёт.

Экран «ждёт разбора» в админке: ручка была, экрана не было. Третья карточка на странице
«Реклама» — клиент, кампания, что робот делал человеческими словами, номер объявления
и на чём споткнулся. В подписи прямо сказано, чего там НЕ будет: обычных отказов,
их клиент разбирает сам.

Дальше — разбор собственной работы этого дня. Найдено четыре ошибки, все исправлены.

1. ТЯЖЁЛАЯ. Доклад разведки на бою уронил бы очередь заданий целиком. Робот пишет в ленту
   под служебной ролью, а у неё на этой таблице было только чтение. Отказ по правам,
   500 роботу, три повтора — и задание навсегда «в работе». Пока хоть одно задание
   в работе, выдача отвечает «работы нет» ВСЕМ клиентам. Лечение — запись схемы v9.17:
   право на запись плюс нумератор. В плане про это было написано прямым текстом,
   я прошёл мимо. Тесты поймать не могли: ходят суперпользователем.
2. Признак «набор создан» я выдумал: взял метку, которая в нашей же разметке описана
   как СКРЫТАЯ галочка. Проверка «видно ли её» не сработала бы никогда. Признак с экрана
   убран совсем: успех определяет портал слепком креативов, а «окно не закрылось» —
   это норма, так и есть живьём.
3. Сломал ленту для повторного отказа. Поменял защиту от дублей на «такой текст уже
   когда-либо был» — и клиент, починивший рекламу и получивший тот же отказ, не увидел бы
   ничего. Вернул сравнение с последним сообщением, а заглушку «причину выясняем» держит
   теперь сам джоб: показываем один раз, пока сказать нечего.
4. Мой собственный тест оказался пустышкой: оставался зелёным при вырезанной защите.
   В нём отклонялись ВСЕ объявления, а тогда кампания уходит в «отклонена» и обход её
   больше не берёт. Сценарий существует только при частичном отказе — тест переписан
   на два объявления и теперь вырезание защиты его роняет.

Портал 391/391, админские экраны 9/9, фронт на затронутых наборах 52/52, робот 74/74,
мест снятия заморозки денег по-прежнему четыре.
На боевой не выкатывалось, рубильник Директа выключен, живьём разведка не гонялась.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:10:18 +03:00
Дмитрий 0ba7890bf9 feat реклама за показы: робот-разведчик приносит клиенту причину отказа с экрана кабинета
Задача 15. Программный интерфейс Яндекса причину отказа не отдаёт — на отклонённое
объявление приходит «Отклонено на модерации.» и всё. Причина висит только на экране
кабинета, и добыть её может лишь тот, у кого есть глаза.

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

Четыре ловушки, пойманные по дороге и проверенные вырезанием:

1. Дедуп разведки нельзя вешать на кампанию. Отказ никуда не девается, а обход бежит
   по расписанию: после закрытия первой разведки поставилась бы вторая, и робот ходил бы
   в кабинет по кругу. Ключ — номер объявления, журнал схемы v9.16.
2. Рубильник держал не выдачу задания, а построение клиента Директа. Разведке слепок
   креативов не нужен, значит при выключенном рубильнике она получила бы задание,
   и робот пошёл бы в живой кабинет.
3. Дедуп ленты сравнивал только с последним сообщением Яндекса. После доклада робота
   обход снова клал бы «причину выясняем» поверх настоящей причины.
4. Постановка разведки шла без tenant-контекста — на бою она не сработала бы ВООБЩЕ
   и молча: поиск дубля давал бы ноль, запись падала бы на политике доступа, всё это
   в предупреждение журнала при зелёных тестах. Поймал rls-reviewer. Лечение — своя
   транзакция с выставлением клиента, рецепт ChargeCampaignSpendJob. Сторож поставлен
   на сам механизм: обычным тестом это не ловится, они ходят суперпользователем.

Заодно: разведке больше не снимается слепок креативов — лишний поход в живой Яндекс
внутри открытой транзакции.

Портал 382/382, робот 75/75, мест снятия заморозки денег по-прежнему четыре.
На боевой не выкатывалось, рубильник Директа выключен.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:28:31 +03:00
Дмитрий 7a4461324e fix реклама за показы: робот грузил файлы в поле, которое Яндекс не принимает
Разметку кабинета 27.07 снимали глазами, ничего не загружая, и поле файлов выбрали
по виду — CreativeActionsMenu.FileInput. Живая загрузка 28.07 показала: это поле файлы
молча забирает, а кнопка «Создать» остаётся серой. Робот падал бы «кабинет не принял
файлы» на каждой попытке. Работает только поле внутри окна загрузки.

Вторая правка из той же поправки разметки: окно после «Создать» живьём НЕ закрывается,
а переключается на вкладку «Мои креативы» со списком наборов. Робот считал это бедой
и слал владельцу письмо-алярм на КАЖДОЙ удачной загрузке. Теперь признак успеха —
появившийся список наборов; человека зовём, только когда исход непонятен: ни списка,
ни закрытия.

Заодно снято лишнее ожидание: раньше на удачной загрузке робот стоял две минуты,
дожидаясь закрытия, которого не бывает.

Обе правки проверены вырезанием по отдельности. Робот 63/63.

Вердикт по второй пробе модерации записан в cabinet-flow.md §7.6: у лицензируемой
тематики окно отказа ровно такое же, поля для документа нет и там. Дороги «отвезти
документ роботом в кабинет» не существует.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:38:45 +03:00
Дмитрий 898f9d575b docs реклама за показы: Яндекс не сообщает машине причину отказа — и это вскрыло дефект
Один запрос на чтение боевым ключом, с разрешения владельца: ads.get на отклонённое
пробное объявление отдаёт StatusClarification = «Отклонено на модерации.» и больше
ничего. На экране в этот же момент — «Нет предупреждения: финансовые услуги» и абзац
с указанием, что дописать в баннер. В подполях Creative и TurboPageModeration причины
тоже нет.

Следствие первое: задача 15 из удобства стала обязательной — без робота-разведчика
портал знает только факт отказа. Проверил до того, как писать код: вывод мог оказаться
и обратным.

Следствие второе, важнее: в куске 1, который считался готовым, живой клиент увидит
пустоту. В переписке — единственная фраза «Отклонено на модерации.», а в списке
кампаний под ярлыком «Отклонено» не будет ничего вовсе: подпись берётся как первая
строка причины, а текст Яндекса начинается с переноса строки.

Тесты этого не ловили — они подставляют выдуманную причину, и на ней всё работает.
Дефект живёт в зазоре между выдуманными данными и живыми. Записан, чинится следующим
коммитом.
2026-07-28 17:05:30 +03:00
Дмитрий d50a93e55c feat(телеграм-робот): боевой режим пересдачи отклонённой кампании (mode:resubmit) — проверено живьём
Хвост №1 из STATE ревью-правок: оформили доказанный в A2 путь пересдачи в
постоянный режим робота mode:'resubmit'. Робот берёт ОТКЛОНЁННУЮ кампанию, входит
в её редактор через «Исправить», вносит исправления и повторно отправляет на
модерацию БЕЗ ОПЛАТЫ (0 ₽ — деньги не списываются).

Что нового:
- parseResubmitTask (task.js): своё задание пересдачи — campaignId + submitMode
  (draft|live, без дефолта, чтобы live не случился сам) + правки на выбор
  (moderatorFile и/или adText/buttonUrl/ordCategory). Нужна хоть одна правка —
  иначе тот же контент снова отклонят. Номера/бюджет не нужны (уже у кампании).
- openResubmitEditor (cabinet.js): нативный клик «Исправить» в строке кампании по
  id → ждём редактор /telegram-a2p/{id}/message (в чужую кампанию не лезем).
- submitWithoutPayment (cabinet.js): на /payment жмём ТОЛЬКО «Отправить на
  модерацию без оплаты»; денежные кнопки («Списать…»/«Оплатить») — двойная защита
  через isForbiddenButtonText, не жмём никогда.
- editResubmitFields + вынос fillAd в хелперы (fillAdText/fillAdLink/
  selectOrdCategory/fillAdMedia): пересдача правит только заданные поля тем же
  проверенным кодом (DRY, поведение fillAd не изменилось).
- runResubmit (runner.js) + ветка mode:'resubmit' в bin/run.js (до parseTask, как
  read-status). draft — предохранитель (до /confirmation, не шлём); live — отправка
  без оплаты.

Живая проверка на реальных отклонённых кампаниях (28.07.2026):
- DRAFT (2231132, правка текста + документ): вошёл «Исправить» → правка → загрузка
  документа → /confirmation → НЕ отправил (resubmitted:false, stoppedAt:confirmation).
- LIVE (2231134, правка текст+ссылка+ОРД, без документа): полный цикл → «без оплаты»
  (0 ₽) → resubmitted:true; read-status подтвердил moderating. Баланс не тронут.

Робот npm test 110/110 (было 94, +16). Приёмочный лист и живые результаты:
docs/superpowers/2026-07-28-robot-resubmit-mode-ACCEPT.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:43:58 +03:00
Дмитрий 22203465ce docs реклама за показы: вторая проба отказа — лицензируемая тематика заведена
В ту же пустышку № 713110757 добавлено объявление № 17787102785 со стоматологией:
медуслуги лицензируются, значит модерация должна потребовать лицензию, то есть
документ. Первая проба дала отказ «поправьте креатив» без документов, поэтому
проверяем, отличается ли окно отказа у лицензируемых тематик. Вердикт ждём.

Попутно подтвердилось на втором случае: сохранение объявления само отправляет его
на модерацию — кнопку «Запустить кампанию» не нажимали, статус сразу стал
«Объявление на модерации». Это подтверждает вывод §7.1 о том, что кнопки повторной
модерации в кабинете нет.

Грабли: переименование набора креативов карандашиком оставляет поле в режиме правки
и перехватывает клик по «Создать» — загрузка встаёт без внятной ошибки. Имя набору
не задаём, берём первый в списке.

Живая кампания владельца № 713051718 не тронута, расход пустышки ноль.
2026-07-28 16:42:21 +03:00
Дмитрий 23db59bd22 docs реклама за показы: экран отказа модерации снят живьём — задача 13 закрыта
Вердикт по нарочно непроходному объявлению пришёл на вторые сутки:
«Модератор отклонил объявление · Нет предупреждения: финансовые услуги».

Разметка записана в bots/yandex-creatives/docs/cabinet-flow.md §7 вместе со снимком
окна отказа. Причина живёт только в списке объявлений — форма объявления знает
«Показы не идут» и молчит про модерацию. Окно открывается кликом, не наведением,
а подробное пояснение видно лишь после раскрытия строки причины.

Два отрицательных ответа важнее найденного:
кнопки повторной модерации не существует — Яндекс шлёт на перепроверку сам
по факту сохранения правки; прикладывать документ в кабинете некуда — в окне
отказа ноль полей для файла, документы уходят наружу через чат или форму
обратной связи. Задача 16 остановлена до проверки вторым отказом
на лицензируемой тематике — решение владельца.

Заодно поправлены даты: работа прошлого захода помечалась 29.07, фактически
всё делалось 28.07. Имена миграций не трогали — они уже закоммичены.
2026-07-28 16:30:54 +03:00
Дмитрий a0b7f4adfa fix(телеграм-робот): чтение вердикта модерации из списка кабинета — починка локатора по живому прогону
Живая проверка A1 (28.07.2026) на реальном отказе кампании 2231134 вскрыла два бага в
readModerationStatus, из-за которых боевой код не находил строку кампании — на юнит-тестах
было зелено, а кабинет показал обратное:

1. Паттерн ссылки: в списке кампаний ссылка = /cabinet/campaigns/telegram/{id}, а код искал
   /telegram-a2p/{id} — это адрес детальной/визард-страницы. Matcher расширен на оба варианта
   через campaignHrefRe/hrefMatchesCampaignId плюс юнит-тест.
2. Глубина подъёма по DOM: строка списка — грид из div, не tr/li; контейнер со статусом ряда
   на ~8 уровней выше ссылки. Предел подъёма поднят с 6 до 10; возврат на первом предке со
   статусом — выше склеиваются две кампании.

Итог живого прогона: робот вернул rejected плюс полный текст 5 пунктов модерации из слайд-модалки
Причины. Метка FLOW-CONFIRM в cabinet.js снята. Робот npm test 94/94.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:46:00 +03:00
Дмитрий 68fe632cca fix(телеграм-реклама): правки по сводному код-ревью ветки — деньги, статус-машина, робот, RLS
Закрывает находки ревью: C1-блокер + рассинхроны длины + все оранжевые. TDD, всё зелёное.
Поведение в песочнице не меняется; правки готовят ветку к боевому включению.

F1 (блокер): AdWalletService::freeze реактивирует released-hold через updateOrCreate по
  4-ключу — пересдача кампании и повторная заявка на имя больше не падают на дубле ключа
  23505 в боевом режиме.
F2: длины валидации выровнены под колонки БД — имя 64, ad_link 500, ord_category 200;
  длинное значение даёт ошибку поля, а не замаскированный 422 от БД.
F3: авто-рассылка морозит потолок бюджета симметрично ручному запуску только в бою и
  считает дневной лимит под lockForUpdate строки правила.
F4: кампания не зависает в moderating вечно — переход moderating→needs_review плюс
  предохранитель уборщика по возрасту client_tg.moderation_stuck_hours=48, бронь не трогаем.
F5: единое осторожное правило возврата брони в finalize и failed — есть mts_campaign_id
  значит могла уйти на модерацию → needs_review без release; нет id → failed плюс возврат брони.
F6: робот cabinet.js — денежные кнопки оплатить/списать/запустить в чёрном списке
  domClickButton, finalize целит только кнопку отправки на модерацию.
F7: finalize live не врёт launched:true на шаге /payment — launched:false, stoppedAt:payment;
  не дошли до /payment → падаем громко.
F8: assertCostWithinCap подключён в live-finalize — сверка фактической стоимости с потолком.
F9: GRANT SELECT служебным ролям на client_tg_campaigns миграцией 000016 — иначе
  кросс-тенантные джобы Poll/Sweep видели бы 0 строк на проде; правка ложного комментария в
  000011. CHANGELOG v8.93, rls-reviewer CLEAN. ДЕПЛОЙ: ПЕРЕзапустить db/03_service_bypass_policies.sql.

Приёмка: бэкенд ClientTg 196/196; робот npm test 89/89; pint/phpstan/deptrac чисто. Фронт не трогали.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:07:53 +03:00
Дмитрий aa4259e76c docs реклама за показы: живая разметка кабинета Яндекса — задача 13 в работе, отказ вызван нарочно
Поправлены три неверные записи прежней разметки, снятой глазами без загрузки файлов:
поле файлов внутри окна вместо CreativeActionsMenu.FileInput, прикрепление креатива
галочкой BatchesList вместо кнопки Выбрать, и список объявлений, который при будущем
сроке кампании отдаёт пусто. Добавлен весь путь мастера создания медийной кампании.

В боевом кабинете заведена отдельная пустышка со сроком в октябре и нулевым расходом:
кампания 713110757, объявление 17787055204 с креативом регулируемой тематики. Живая
кампания 713051718 не тронута. Ждём вердикт модерации, чтобы снять экран отказа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:06:17 +03:00
Дмитрий 18135b47b4 feat(телеграм-реклама): Этап 3.5/3.6 + 4.3 — чтение вердикта, пересдача, уведомление об одобрении
Закрывает Этап 3 «Жизненный цикл и модерация» целиком (3.1–3.6) и бэкенд-часть 4.3.
План docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.

## 3.5 — робот читает статус/причину модерации по mts_campaign_id (Node)
- `parseModerationStatus` (cabinet.js) — чистый парсер статус-текста кабинета в канон
  Laravel-опросчика: «Отклонена»→rejected, «Одобрена»/«Активна»→approved,
  «На модерации»→moderating, «Черновик»→draft, иначе null (тогда опросчик ждёт).
  Терминальные вердикты приоритетнее слова «модераци…» в строке-блобе. Юнит-тест
  read-status.test.mjs (11 кейсов: регистр/nbsp/блоб/приоритет/мусор).
- `readModerationStatus` (cabinet.js) + `runReadStatus` (runner.js) + режим `read-status`
  в bin/run.js: открывает список кампаний, находит ряд по id, читает статус; при отказе —
  причину из слайд-модалки «Причины» (#slide-modal-root). Отдаёт JSON
  {ok, moderationStatus, reason?, campaignId} — его уже разбирает RobotResult (3.4).
  🔴 Читалка кабинета помечена <FLOW-CONFIRM>: DOM-обёртки ряда/модалки собраны по
  живой разведке «Сессии 6» (FLOW-FINDINGS.md), но именно этим кодом live ещё не прогнаны —
  подтвердить на следующем цикле модерации с разрешения владельца. Парсер от DOM не зависит.

## 3.6 — пересдача отклонённой кампании (rejected → queued + документ модератору)
- Миграция 000013: колонка `client_tg_campaigns.moderator_file_path` (varchar 500 NULL,
  после media_path) + CHANGELOG схемы v8.90; rls-reviewer прогнан — чисто (nullable-колонка
  данных, не tenant-скоуп, RLS не меняется). Модель — fillable.
- Endpoint `POST /api/telegram/campaigns/{id}/resubmit`: только отклонённую (иначе 422);
  правки ad_text/ad_link/ord_category (валидация как store) + опц. файл модератору
  (.png/.jpeg/.jpg/.pdf ≤10 МБ, сохраняется на диск local). Успех: поля обновлены,
  status_reason и mts_campaign_id очищены (робот создаст новую кампанию в кабинете),
  rejected→queued, dispatch RunTelegramCampaignJob afterCommit. В бою — гейт аудитории
  + freeze budget_cap_rub заново (при отказе бронь вернул опросчик 3.4; freeze идемпотентен
  по ACTIVE-холду), нехватка → 409, остаётся rejected. Песочница — без брони.
- Робот: task `moderatorFile` (task.js passthrough + TelegramRobotRunner.taskPayload),
  RunTelegramCampaignJob отдаёт moderator_file_path; fillAd грузит файл в поле «Комментарий
  для модератора» (третий file-input, accept pdf) — помечено <FLOW-CONFIRM> (live не прогнан).
- Тесты: ResubmitTest.php (7 кейсов: rejected→queued+очистка+джоб / файл сохранён /
  не-rejected→422 / live 409 / валидация / .exe→422 / чужой→404); Node task.test.js (+2).

## 4.3 — уведомление об одобрении (бэкенд был готов в 3.4, добор покрытия)
- ApproveNotifyTest.php (4 кейса): notifyTelegramCampaignApproved шлёт in-app всем активным
  юзерам тенанта без pref-гейта, тело «одобрена/показы пошли»; неактивный/чужой не получают.

TDD. Приёмка (моя область, чистый прогон): весь ClientTg 150/150, робот 78/78,
ApproveNotify 4/4; phpstan 0, deptrac 0, pint чисто.

Приёмочный лист 3.6 — docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md.
Осталось в Этапе 4: фронтенд 4.1/4.2/4.4/4.5 (Vue-экран + Vitest) — НЕ начато.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 11:29:23 +03:00
Дмитрий 27e9591846 feat(телеграм-реклама): Этап 3.1b — инструмент уборки осиротевших черновиков МТС
Этап 3 «Жизненный цикл и модерация», задача 3.1b. Робот создаёт реальный
черновик в кабинете уже на шаге «Аудитория»; упавший прогон плодит осиротевшие
черновики (27.07 чистили руками, 2 шт.). Узаконенный инструмент вместо ручного.

- src/cleanup-drafts.js — чистые предикаты (покрыты юнит-тестом) + браузерная
  обёртка cleanupDrafts(). Предикаты: isDeletableDraftRow (только строка-черновик
  «Telegram по своей базе», НЕ «на модерации»/«отклонена»/«активна», НЕ шапка),
  isHeaderOrAggregateRow, selectionIsSafe (гейт перед удалением: ≥1 выбран, ВСЕ
  черновики, select-all снят — иначе аварийный стоп).
- bin/cleanup-drafts.js — CLI: read-only по умолчанию (показывает, что БЫ удалил);
  удаление только по явному флагу --delete, через сверку selectionIsSafe.
- Логика перенесена с проверенного живьём 27.07 ручного скрипта; критический гейт
  проходит через Node-предикат selectionIsSafe (а не только браузерный скан).
- Процедура задокументирована в FLOW-FINDINGS.md.

Приёмка: юнит cleanup-drafts 17/17, весь Node-набор 65/65 (npm test). Без БД,
без денег, без выхода в кабинет.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 08:56:44 +03:00
Дмитрий 73bd36b6ce feat(телеграм-реклама): Этап 3.1 — id кампании МТС хранится рано и при отказе (+ разведка экрана отказа)
Этап 3 «Жизненный цикл и модерация», задача 3.1. Плюс закрыта задача 3.0
(живая разведка экрана отказа) — вердикт МТС по кампании «займ» (2231134)
пришёл: «Отклонена». Разведка read-only, деньги не тронуты.

Задача 3.1 — колонка mts_campaign_id + РАННЕЕ и надёжное сохранение:
- Миграция client_tg_campaigns.mts_campaign_id (varchar(32) NULL, после
  status_reason) + запись CHANGELOG_schema v8.89 (предварит., ветка). RLS не
  меняется; rls-reviewer не требуется (nullable-колонка данных).
- Робот (Node): чистый хелпер parseCampaignId(url) в cabinet.js (покрыт
  тестом); runner.js захватывает id СРАЗУ после создания черновика (шаг
  аудитории) и печатает маркер MTS_CAMPAIGN_ID=<id> в stderr; id теперь
  идёт и в ветке ОТКАЗА (раньше терялся).
- Обёртка (PHP): TelegramRobotRunner восстанавливает id из stderr-маркера во
  всех путях (таймаут/непарсабельный вывод/JSON без id); RobotResult::failed
  принимает id.
- Джоб: finalize сохраняет mts_campaign_id при ЛЮБОМ исходе (успех/отказ), не
  затирая ранее сохранённый id. Метод failed() не трогали — туда результат не
  доходит (осознанный residual, закроют уборщик 3.1b и sweeper 3.3).

Разведка отказа (3.0) записана в bots/mts-telegram-ads/FLOW-FINDINGS.md:
причина показана текстом в слайд-модалке «Причины отклонения кампании»
(кнопка «Причины»); поля загрузки файла на экране отказа нет — документ
грузится через «Исправить» → шаг «Сообщение» → «Комментарий для модератора»;
кнопка пересдачи — «Исправить».

TDD, робот замокан, тесты на liderra_testing (номера 7999…).
Приёмка: Node 48/48 (npm test), Pest ExternalIdTest 2/2 + регрессия ClientTg
122/122, phpstan (4 боевых файла) 0, deptrac 0 нарушений, pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 08:49:01 +03:00
Дмитрий 5211048bce feat(телеграм-реклама): Этап 2 закрыт — робот несёт фактическую стоимость (2.2), билинг МТС подтверждён
Задача 2.0 (разведка) — РЕШЕНА без входа в кабинет: ответ уже был в
находках Фазы 2. Билинг МТС за «показы своей базе» — НАКОПИТЕЛЬНЫЙ
(резерв → списание по факту показов → возврат остатка, экран /payment).
Следствие: списание «по факту» (была задача 2.4) на успехе отправки честно
сделать нельзя — показов ещё нет; перенесено в Этап 3 (опрос завершения).
Этап 2 закрыт составом 2.1 (отмена) + 2.3 (возврат брони) + 2.2.

- 2.2 PHP: RobotResult несёт `actualCostRub` (nullable string) — заготовка,
  чтобы позже (Этап 3) прочитать фактическую стоимость из кабинета и списать
  её с кошелька клиента. Проброшено в fromRobotJson (робот начнёт класть поле
  позже; нет поля → null). Мёртвых фабрик launched()/draftReady() не добавлял.
- 2.2 Node: чистая утилита `parseCost(text)` в cabinet.js — «Стоимость
  кампании от 201,6 ₽» → «201.60» (запятая→точка, разделители тысяч включая
  неразрывный пробел код 160, дробь до 2 знаков без округления, нет числа →
  null). Вынесена отдельной покрытой функцией; к DOM-потоку НЕ подключена
  (селектор строки стоимости подтвердим живьём в Этапе 3).
- Разведка билинга и решение по 2.4 зафиксированы в FLOW-FINDINGS.md; план
  Этапа 2 обновлён (2.0 решён, 2.4 → Этап 3, Этап 2 закрыт).

TDD, робот замокан, тесты на liderra_testing.
Приёмка: Node 40/40 (npm test), Pest RobotResultTest 5/5 + RobotRunnerTest
(потребитель) зелёный, регрессия ClientTg 120/120, phpstan RobotResult.php 0,
pint чисто, deptrac 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 07:52:56 +03:00
Дмитрий af12b1ceb4 fix реклама за показы: предел веса картинки проверен по-настоящему, обход модерации не срывается целиком, робот не несёт токен на чужой адрес
Мелочи приёмочного листа v12 §8. Каждая правка с тестом; где защита уже стояла
в коде — тест проверен вырезанием этой защиты.

Предел веса картинки. Две прежние проверки были пустышками: сравнивали константу
саму с собой и с тем же числом в ответе сервера. Вырезание правила max: оставляло
обе зелёными. Настоящий тест грузит перевес и ждёт отказа — это четвёртая найденная
пустышка за ветку.

Обход модерации. Объявление без статуса и причина отказа длиннее колонки роняли
запись в базу ВНЕ защиты, и обход обрывался на середине: остальные клиенты не узнавали,
приняли их рекламу или отклонили, а деньги за отклонённый набор не возвращались.
Запись ответа теперь под той же защитой, что и сеть; пустой статус не пишем вовсе,
причину храним обрезанной.

Робот. Адрес файла приходил в ответе сервера, а шли по нему со своим токеном без
всякой сверки. Теперь адрес обязан вести на портал. Папка снимков экрана росла
бесконечно, а на снимках видны логин и остаток счёта — старше двух недель убираются.

Ещё: порядок посредников служебного канала — токен раньше служебного соединения;
BannerGenerator больше не отдаёт молча файл тяжелее предела и берёт предел из общей
константы; нулевой номер креатива ловится намеренно, а не случайно нестрогим сравнением.

Портал 300/300, робот 60/60. Мест снятия заморозки денег по-прежнему четыре.

Не тронуто намеренно: цена за 1000 показов и бюджет приходят от клиента — но это
видимое поле мастера и принятое продуктовое решение, а не недосмотр. Решает владелец.
2026-07-28 07:21:02 +03:00
Дмитрий b3a86e69ad feat(телеграм-реклама): Этап 1 — безопасность и защита входа модуля «по своей базе»
Закрыты 5 находок аудита (безопасность и валидация входа, деньги не задеты):

- #11 ПДн-скрины робота: полноэкранный скриншот кабинета МТС (мог содержать
  телефоны базы, 152-ФЗ) больше не снимается и не уходит письмом по умолчанию —
  только по явному TG_DEBUG_SHOTS. Чистый хелпер src/shots.js.
- #12 стоп-лист opt-out теперь нормализуется при сравнении (8XXXX / 10-значные
  формы вычищаются), сравнение по голому 7XXXXXXXXXX.
- #13 верхние пределы входа: phones max:200000, phones.* max:32, audience_days
  max:365; store возвращает dropped_count (сколько номеров не распозналось).
- #14 идемпотентность запуска: Фаза A джоба читает кампанию с lockForUpdate —
  два воркера сериализуются на блокировке строки (защита от гонки).
- #2 предстартовый гейт аудитории: в боевом режиме launch НЕ бронирует деньги
  под кампанию с <367 / пустой аудиторией (иначе бронь залипала бы). Порог —
  config client_tg.auto_batch_threshold (367). В песочнице гейта нет.

TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера).
Приёмка: Node 28/28, Pest ClientTg 105/105, phpstan 0, pint чисто, deptrac 0.
Обновлён CampaignApiTest (тест «денег не хватает → 409» засеян ≥367 контактами,
чтобы дойти до проверки денег после нового гейта).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 06:37:25 +03:00
Дмитрий 9cc3f5e950 fix реклама за показы: пауза не врёт про остановку, цена запуска остаётся на кампании, робот не дерётся сам с собой
Хвосты денег Д1-Д6 и робота Р-х1-Р-х6 из приёмочного листа v12.

Д1 цена, по которой заморожены деньги, записывается на кампанию. Пока поле было
пустым, списание читало глобальную цену — админ менял её, и клиент платил больше
обещанного при запуске.

Д2 суточное списание берёт кампанию под замком строки. Ключ идемпотентности зависит
от числа показов, поэтому два одновременных прогона получали разные ключи и списали
бы клиента дважды.

Д3 пауза, не дошедшая до Директа, больше не считается паузой: отказ 409, заморозка
остаётся. Раньше реклама крутилась дальше, портал показывал паузу, а деньги были уже
свободны. У возобновления поведение намеренно прежнее — иначе понадобилось бы пятое
место разморозки, а их ровно четыре. Там же убрана мина строгого сравнения рубильника.

Д4 не трогали — это вопрос владельца.

Д5 рубильник Директа держит и служебный канал робота: выдача задания и приём отчёта
ходили в живой кабинет мимо него.

Д6 проверка рубильника приведена к общему виду: YANDEX_DIRECT_ENABLED=0 давало строку,
которую строгое сравнение читало как включено.

Р-х1 настройки читаются из .env робота, а не каталога запуска.

Р-х2 файл-замок robot.lock: проход и поддержание входа больше не дерутся за профиль
браузера. Занят — уходим молча, задание остаётся в очереди. Брошенный замок
перехватывается через полчаса.

Р-х3 письмо-алярм честно говорит, залиты ли уже креативы в кабинет. Побочно вскрылось,
что тексты писем не проверялись ни одним тестом — транспорт вынесен в src/smtp.js.

Р-х4 тест-пустышка про рабочую папку заменён настоящим: запуск из чужого каталога без
явной папки. Проверено вырезанием.

Р-х6 пустое значение в окружении читается как значение по умолчанию, мусор даёт внятную
ошибку вместо тихого NaN.

Портал 293/293, робот 57/57. Денежных выходов снятия заморозки по-прежнему четыре.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:36:11 +03:00
Дмитрий 0548fc0d8f fix реклама за показы: слепок креативов при выдаче, сверка размера перед объявлением, один баннер на слот
Хвосты портала П1-П8 из приёмочного листа v12.

П1 обрыв постановки задания больше не даёт клиенту голый 500 — 503 с человеческим
текстом и записью в журнал. Таймаут у HTTP-клиента Laravel уже был, эта половина
находки не подтвердилась.

П2 и П6 роботу отдаются только баннеры без номера креатива — тот же список
сопоставляется при отчёте. Раньше стороны расходились и опознание падало на
безупречной работе робота, плодя дубли в кабинете. Плюс постраничный обход описи
креативов: слепок обрывался на первой странице.

П3 слепок «до» снимается при выдаче задания, а не при постановке, и в той же
транзакции, что и перевод в работу. Два задания в очереди получали одинаковый
слепок, второе падало всегда. Постановка перестала зависеть от живости Яндекса.

П4 перед созданием объявлений сверяется настоящий размер каждого креатива одним
запросом. Не сошлось или креатива нет — запуск не идёт.

П5 уникальный индекс uq_ad_campaign_banner_slot, запись v9.09 в CHANGELOG схемы,
rls-reviewer GO.

П7 замок на правку расширен на сегмент Аудиторий — он создаётся раньше кампании.
Смежная находка: перезаливка картинки теперь обнуляет номер креатива.

П8 и Р-х5 файл отдаётся под настоящим расширением и типом содержимого, имя от
номера баннера; робот берёт расширение из ответа портала.

Портал 287/287, робот 43/43. Денежных выходов снятия заморозки по-прежнему четыре.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 05:37:56 +03:00
Дмитрий 542b7d2d22 fix реклама за показы: очередь робота больше не встаёт колом, доклад об успехе не врёт, тихий ноль ловится
Р6 — задание, брошенное «в работе», держало очередь для всех клиентов навсегда.
Причина сбоя обрезается до 900 знаков, иначе портал отвергал отчёт о самом частом
виде поломки. Провал отчёта больше не глотается молча — едет в письмо человеку.
Приём отчёта ловит любую ошибку и закрывает задание сбойным вместо 500 роботу.
Новый сторож creative-jobs:reap каждые 10 минут разбирает пробку.

Р7 — обрыв на докладе «готово» объявлял провалом уже сделанную работу. Доклад
вынесен из-под обработчика сбоя, повторяется трижды, при неудаче только письмо.
Осечка ухода со страницы после «Создать» больше не считается провалом загрузки.

Р8 — пустой список размеров считался успехом, и робот заливал одни и те же файлы
по кругу, оставляя мусор в живом кабинете. Теперь громкий отказ до похода в Яндекс.

Портал 274/274, робот 41/41. Все защиты проверены вырезанием.
Выходов снятия заморозки денег по-прежнему четыре.
2026-07-27 20:42:23 +03:00
Дмитрий cc985e07de feat(телеграм-реклама): экран «реклама отклонена» + закалка робота против глюков МТС
Клиентский модуль «Реклама в Телеграме по своей базе» (робот-в-браузере, МТС Маркетолог).

Задача 5.2 — обратная связь по отказу:
- миграция client_tg_campaigns.status_reason (varchar 500, nullable; RLS без изменений — колонка на существующей таблице), запись v8.88 в CHANGELOG_schema
- Campaign: status_reason в fillable
- NotificationService::notifyTelegramCampaignRejected — in-app уведомление всем активным
  пользователям тенанта без pref-гейта (важное операционное сообщение доходит всегда)
- RunTelegramCampaignJob: при провале робота сохраняет причину в status_reason и шлёт уведомление
  (Tenant грузится внутри tenantTx для RLS, notify — после транзакции)
- фронт: telegram.ts (+status_reason), AdvertisingTelegramView показывает причину отказа/ошибки
- тесты: RejectNotifyTest (4 Pest) + 2 Vitest на экран

Закалка робота против частых глюков кабинета МТС:
- browser.js: gotoStable() — терпеливая загрузка с перезагрузкой (кабинет виснет на пустой крутилке)
- session.js: isLoggedIn через gotoStable — больше нет ложного «вход слетел»
- cabinet.js: знакомство пропускается если его нет (только для новичков); оферта по #isOfferAccepted;
  осознан шаг /payment (денежная развилка «оплатить/без оплаты» — Сессия 6)

Живая разведка кабинета зафиксирована в FLOW-FINDINGS.md (селекторы, шаг /payment, поле файла модератору).

Проверки: Pest ClientTg 93/93, Vitest экрана 7/7, node-тесты робота 20/20.
Робот в тестах замокан, тесты на liderra_testing. Реальных ПДн нет (номера фейковые 7999…).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 20:10:10 +03:00
Дмитрий b8f75b2a0c fix реклама за показы: файл робота только по своему заданию, замок на баннерах, защита от двойного запуска
Р3 хвост. Адрес файла баннера стал /api/creative-robot/jobs/{jobId}/banners/{bannerId}/file.
Раньше портал подставлял «какое-нибудь задание в работе» — защита выдачи чужих картинок
держалась на внешнем условии, а не на самом запросе. Робота править не пришлось: он берёт
адрес из ответа портала как есть. Запись v9.08 в журнал схемы про частичный уникальный
индекс; rls-reviewer по миграции — GO.

Р4. Замок на наборе баннеров после заведения кампании в Яндексе: перезаливка, включение
и удаление отдают 409 по тому же признаку yandex_campaign_id, что и замок на параметрах.
Без него в портале была новая картинка, а в Яндексе крутилась старая, а удаление строки
с номером объявления заставляло возобновление завести второе объявление того же размера —
старое продолжало крутиться за деньги клиента. Баннер с номером объявления не удаляется
никогда.

Р5. Захват кампании под запуск: новый промежуточный статус launching, перевод под замком
строки в транзакции. Два одновременных нажатия «запустить» проходили проверку статуса оба
и заводили две CPM-кампании при одной заморозке денег. На любой ошибке прежний статус
возвращается, номера созданных в Яндексе сущностей уцелевают — возобновляемый запуск
не тронут. Брошенный захват старше пятнадцати минут перехватывается.

Тесты: портал 266/266, робот 35/35. Все новые защиты проверены вырезанием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:06:57 +03:00
Дмитрий 20d9dc6d3a feat робот креативов: цикл работы, сторож, письма и описание для человека
Любой сбой даёт отчёт порталу и письмо со снимком экрана, кампания остаётся
черновиком. Отчёт порталу идёт ПЕРВЫМ: упавшая почта не должна подвешивать
задание в работе. Картинки клиента стираются при любом исходе.

Поправлены два расхождения плана с живым кодом: в кабинет уходят пути к файлам,
а не описания баннеров, и папки считаются от корня робота, а не от каталога
запуска - робота запускает расписание.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:22:35 +03:00
Дмитрий 812fed7772 feat робот креативов: браузер, проверка входа и загрузка креативов по живой разметке кабинета
Заглушка плана заменена настоящим кодом: разметка снята в задаче 11.

Прихожая — существующие кампания и группа из настроек: своей кампании у робота
в этот момент ещё нет, портал ставит задание до её создания в Яндексе.

Окно, не закрывшееся после Создать, провалом НЕ считается — оно может остаться
открытым и после удачной загрузки. Робот закрывает его крестиком и отдаёт слова
кабинета наружу, а приговор выносит портал по слепку креативов.

Кнопку Сохранить изменения робот не нажимает никогда — на это есть тест-сторож.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:18:39 +03:00
Дмитрий 602b8f5cb2 feat робот креативов: разговор с порталом — взять задание, скачать файлы, отчитаться
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:02:45 +03:00
Дмитрий c548fb73b7 feat робот креативов: скелет проекта и конфиг
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:02:30 +03:00
Дмитрий 5b8813b033 docs робот креативов: разметка экранов загрузки в кабинете Директа
Снято живьём вместе с владельцем в кабинете sasha261185, кампания 713051718.
Ничего не сохранялось и не создавалось: до и после — те же 15 объявлений,
кнопки «Сохранить изменения» и «Создать» не нажимались.

Ключевое для задачи 14: системное окно выбора файла роботу не нужно —
в форме есть input type=file с data-testid CreativeActionsMenu.FileInput,
принимает png/jpeg/gif и сразу несколько файлов. Весь путь до окна загрузки
проверен программным прогоном, окно открылось.

Подтверждено живьём: конструктор креативов закрыт, Яндекс пишет это сам;
у всех 15 креативов имя «Новый креатив», потому что поле имени одно на всю
загрузку — опознание по разнице слепков и размеру остаётся единственным
надёжным способом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:41:56 +03:00
Дмитрий 522e160cfb feat(телеграм-бот): робот проходит визард МТС насквозь до «Подтверждения» (draft)
Живой прогон runner в режиме draft отработал целиком: визард -> аудитория
(matched=437) -> объявление -> стоимость -> подтверждение -> скрин; кнопку
отправки не жмём, баланс 5010 РУБ не тронут. Юнит-тесты 20/20.

- обход коуч-марка: нативный .click() по DOM-кнопке (domClickButton/continueTo),
  т.к. оверлей перехватывает мышиный клик даже с force
- фикс чтения аудитории «Не МТС» (неразрывный пробел, код 160) в readNonMts
  + поллинг завершения асинхронного матчинга
- надёжная категория ОРД (combobox: fill -> pressSequentially -> role=option)
- шаги submitAd->/budget, submitBudget->/confirmation; finalize на /confirmation
  (draft=скрин без клика; live=оферта+отправка, за FLOW-CONFIRM)
- fillAd: убраны adTitle/ordCompanyName (полей в кабинете нет, рекламодатель авто)
- task.js/тесты/example: убраны adTitle/ordCompanyName/cpmRub; budgetRub оставлен
  как потолок стоимости для боевого запуска
- FLOW-FINDINGS.md: полная карта флоу + две ключевые грабли и их решения

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:36:55 +03:00
Дмитрий 741d1697c5 feat(телеграм-бот): бот-автоматизатор кампаний МТС Маркетолог — cabinet/runner/CLI/keepalive (задачи 10–16)
Дописан standalone-робот (Node ESM + Playwright) полного цикла создания
Telegram-кампании по своей базе телефонов в кабинете МТС Маркетолог:
- cabinet.js: вход в визард, выбор «Своя база клиентов», загрузка базы +
  подсчёт «не МТС» с порогом 367, переход «Аудитория→Объявление» (само-
  верификация), заполнение объявления + блок ОРД, финал черновик-стоп/боевой
  (fail-loud при неизвестном режиме — защита от боевого клика по ошибке).
- runner.js: оркестратор с алярм/отчётом на почту, гарантированное закрытие
  профиля браузера, чистка временного файла номеров (152-ФЗ).
- task.js: обязательные поля заголовка и названия ОРД; config: потолок бюджета
  падает при нечисловом значении (защита денег), budget: NaN-guard.
- bin/run.js (CLI), bin/keepalive.js (сторож входа с алярмом), bin/login.js
  (разовый вход), task.example.json (без ПДн), README.
- Шаг «Стоимость» намеренно не размечен (заглушка бросает Error) — селекторы
  снимаются на первом реальном черновике после разового логина владельца.

Ядро: 20/20 юнит-тестов зелёные. Браузерная часть проверяется живым черновиком.
Двухстадийная ревизия каждой задачи + финальный сквозной проход (поймал и
починил пропущенный переход между шагами и NaN-обход потолка бюджета).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 00:43:20 +03:00
Дмитрий 51aac64c8a docs(телеграм-бот): разметка подтверждена живым прогоном до шага «Объявление»
Прогон в черновике на реальной базе (1740 номеров): Получатели 1709, МТС 713,
Не МТС 996, стоимость показов 478,08 ₽ — цепочка «загрузка базы → счётчик → цена»
работает. Шаг «Объявление» размечен полностью (текст/заголовок/ссылка/медиа +
обязательный ОРД-блок: категория downshift, название, «Я — посредник»). Добавлены
грабли автоматизации (coach-mark overlay, downshift, force-click). Шаги
«Стоимость/Подтверждение» — доразметить на первом реальном черновике бота.
Черновик удалён, баланс 5010 ₽ цел, временные ПДн стёрты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 23:34:48 +03:00
Дмитрий f747b2fc74 feat(телеграм-бот): персистентный браузер и проверка живости входа
Задачи 8–9 плана (код, проверка против кабинета — при логине владельца в
профиль бота):
- browser.js — launchPersistentContext(profileDir), humanPause по темпу
- session.js — isLoggedIn по стабильному пункту меню «Рассылки и звонки»
  (селектор подтверждён живьём при разведке)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 22:49:22 +03:00
Дмитрий a1492400a0 feat(телеграм-бот): тестируемое ядро — config, task, phones, budget, mailer
Задачи 3–7 плана (TDD, 14/14 зелёные):
- config.js — загрузка/валидация настроек окружения
- task.js — парсинг и валидация задания кампании (режимы draft|live)
- phones.js — нормализация и дедуп файла номеров (7XXXXXXXXXX)
- budget.js — предохранитель потолка бюджета
- mailer.js — письма алярма/отчёта через SMTP (nodemailer)

gitleaks: каталог test/ бота добавлен в allowlist (синтетические
телефоны-фикстуры 345-67-89 / 111-22-33, не реальные ПДн).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 22:47:15 +03:00
Дмитрий 22d1150e26 chore(телеграм-бот): скелет Node+Playwright проекта
Задача 2 плана: package.json (Node ESM, node --test), .env.example (профиль
браузера, SMTP Unisender Go, потолок бюджета, человеческий темп), .gitignore.
Зависимости playwright/nodemailer/dotenv + chromium установлены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 22:44:52 +03:00
Дмитрий ea1991e926 docs(телеграм-бот): разметка визарда кампании по своей базе в кабинете МТС
Задача 1 плана: пройден визард Telegram Ads → «Своя база клиентов» в живом
кабинете в режиме только-чтение. Зафиксированы 7 шагов степпера, селекторы,
требования к файлу номеров (TXT/CSV/XLS/XLSX, формат 79XXXXXXXXX, минимум 367
не-МТС), счётчик «МТС/Не МТС», путь удаления черновика. Черновик 2229823 убран,
баланс 5010₽ не изменился. cspell-words: добавлены CPM/CPF/MTC/ЕРИР/Таргеты/алярм.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 22:33:03 +03:00