Commit Graph

67 Commits

Author SHA1 Message Date
Дмитрий a5bd891d0a fix: сторож заготовок ловит запись разговора, переименованную в заготовку
Надзиратель вскрыл дыру в самом опасном месте. Мой замок на личные данные
смотрел только на имя файла, а имя подделывается первым. Запись разговора,
положенная как obraztsy/Kakoe-to-imya.mp3 и вписанная в перечень слепков,
проходила ВСЕ проверки насквозь.

Замков стало три, и каждый ловит своё:
- по имени, был — ловит небрежность, ничего не стоит, работает без машины;
- по слепку, новый — слепок заготовки сверяется со слепками живых записей на
  машине; имя обмануть можно, слепок нельзя;
- по содержимому, новый — запись разговора всегда WAV и всегда начинается с
  RIFF; любой RIFF среди заготовок обязан совпасть с одним из трёх известных
  слепков. Ловит подлог даже тогда, когда оригинал на машине уже съеден
  месячной уборкой.

Три известных слепка прописаны в самом стороже, а не берутся из перечня:
перечень подделывается тем же движением, что и подмена.

Машину не грузим: сначала сравниваются размеры, и только совпавшие по размеру
записи считаются слепком. Точная копия весит ровно столько же, значит замена
полная. Сегодня совпадений по размеру ноль — вместо чтения 63 МБ звука машина
читает одно оглавление. Решение владельца Р84.

Показано красным двумя ножами, и ни одного чужого байта в дереве:
- настоящая запись с машины, у которой длина звука ноль отсчётов, положена под
  безобидным именем — замки 2 и 3 назвали её поимённо, замок 1 остался зелёным;
- подлог настоящего веса собран из наших же заготовок в отдельной папке на
  машине — замок 2 назвал оба файла.
Подложенное убрано, возврат доказан cmp против содержимого коммита.

Чего и новая защита не ловит: запись, ПЕРЕСЖАТУЮ в mp3. Записано в отчёте.

Мерки: сторож 23 из 23, красных 0. Живая папка на машине не тронута.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:30:22 +03:00
Дмитрий 588f05aef1 feat: заготовки подбора голоса Лены сохранены и возвращаются после перезагрузки
Оснастка подбора голоса жила только в /tmp на машине робота, а /tmp вычищается
целиком при каждой перезагрузке правилом D /tmp. Машина не перезагружалась 31
сутки — в день перезагрузки работа исчезла бы молча. Решение владельца Р107.

Что сделано:
- 43 файла заготовок положены в bots/lena-golos/zagotovki — 4 файла подставного
  собеседника chelovek* и 39 в восьми папках подбора. Перенос поимённым
  перечнем; записи разговоров не тронуты ни одной;
- постоянная копия на машине в /home/ubuntu/lena-inworld/zagotovki;
- правило воссоздания /etc/tmpfiles.d/lena-zagotovki.conf, 12 строк C,
  эталон в bots/lena-golos/lena-zagotovki.conf, побайтово равен серверному;
- сторож proverka-zagotovok.sh — 21 проверка: хранилище по слепкам, машина
  побайтово, текст правила, и живой опыт воссоздания на подставном дереве;
- слепки всех 43 файлов в zagotovki-slepki.sha256;
- раздел в README про заготовки и возврат руками.

Замерено, а не предположено: порядок фаз systemd-tmpfiles — стирание идёт
раньше создания в одном вызове теми же ключами, какими зовёт загрузка. Правило
лечит только полное отсутствие: частичную порчу оно пропускает молча — записано
в самом правиле, в README и ловится сторожем.

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

Мерки: Obzvon 214 из 214, 958 утверждений, красных 0.
Сторожа машины 26, 46, 27 — красных 0, число проверок не уменьшилось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:58:23 +03:00
Дмитрий 76944fb46f docs: описан текстовый след разговоров и как проверить, что уборка жива
README Лены: новый раздел про текстовый след - четыре места, что сделано у
источника, почему живой журнал вращается, а законченная выжимка удаляется
по возрасту, где что лежит на сервере и чем всё это проверить.

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

Отчёт круга дописан целиком: замеры, семь ножей, разбор задания.
2026-08-06 08:21:37 +03:00
Дмитрий 53288b5cfd feat: убран текстовый след разговоров и назначен срок хранения три месяца
Мост писал в журнал дословные слова человека и его имя. Журнал живёт не во
временной папке, его не чистит даже перезагрузка, и срока хранения у него
не было никакого - слова человека лежали дольше его же голоса.

Что сделано:
- most.py пишет меру реплики вместо самой реплики: когда заговорил, сколько
  сказал, на каком языке расслышали. Имя человека спрятано и в строке
  "кому звоним", и внутри речи Лены. Накопитель дословных слов в памяти
  службы убран - его никто не читал;
- tekstovyy-sled.logrotate - вращение журналов моста и летописи звонков,
  срок 90 суток, решение владельца Р104. Вращение, а не удаление по
  возрасту: живой журнал по возрасту не стареет никогда;
- chistka-sleda-prob.sh и chistka-sleda-prob.cron - уборка выжимок проб
  по тому же сроку;
- proba-mosta-bez-zvonka.py - проба моста подставными посылками Inworld,
  без живого звонка;
- proverka-tekstovogo-sleda.sh - сторож по каждому из четырёх мест отдельно.

Чужие файлы не тронуты. Оба чужих сторожа зелёные.
2026-08-06 07:57:09 +03:00
Дмитрий 8df431a4ea fix: две беды по приговору — слепая проверка расписания и права папки после перезагрузки
Беда 1. Проверка «расписание зовёт chistka-zapisey.sh» считала совпадения по
всему файлу вместе с пояснениями и удовлетворялась собственным комментарием:
подмени строку запуска на вызов несуществующего файла — проверка оставалась
зелёной. Заменена на четыре зрячие: сторож берёт саму строку запуска, достаёт
из неё пользователя и путь и спрашивает, существует ли то, что расписание
вправду зовёт, и тот ли это скрипт.

Беда 2. Доказательство «ubuntu удаляет файлы asterisk» было сделано на здоровом
случае. Папка живёт в /tmp и исчезает при перезагрузке, дальше её создаёт заново
демон Asterisk. Замерил его umask — 0022, значит папка возродится с правами 0755,
и уборка не удалила бы НИ ОДНОГО файла, оставаясь на вид живой. В приговоре было
0775, на деле хуже: 0755. Проверено с контролем «файл вправду существует».

Лечение: правило /etc/tmpfiles.d/lena-inworld.conf, которое воссоздаёт папку при
каждой загрузке с явными правами 0775 asterisk:ubuntu. Эталон положен в
хранилище как lena-inworld.conf, слепки сходятся побайтово. Не root — root в
папке, открытой всем, хуже лечимой беды, и журнал стал бы его. Не общая группа —
после перезагрузки права всё равно 0755, группе там писать нечем.

Сторож вырос с 17 до 26 проверок. Главная новая — замером, а не рассуждением:
кладёт подставной файл от asterisk и пробует удалить его от того пользователя,
который взят из самой строки расписания, потом убирает за собой.

Каждая из девяти новых проверок показана красной восемью ножами, включая нож
надзирателя и нож приговора. Нож B нарочно отделяет «зовёт не тот скрипт» от
«зовёт то, чего нет» — проверки независимы.

Не согласен с одной строкой приговора, и с замером: ругань уборки НЕ уходит в
/dev/null. Скрипт пишет каждую строку и на экран, и в журнал, а на ругань в
расписании стоит дозапись в тот же журнал. Показал живым прогоном: строка
«не вышло удалить» в журнале есть. Суть приговора это не меняет — журнал никто
не читает.

Записей было 86 и осталось 86, слепок списка совпал с началом смены байт в байт.
Мусора на машине не осталось, проверено командой.
2026-08-06 06:38:18 +03:00
Дмитрий f4c3220ffd fix: уборка записей Лены заведена на самой машине робота, а не только на бумаге
Была беда: скрипт уборки жил только в хранилище. На машине робота не было ни
файла, ни расписания — замерено тремя приборами. Значит голоса людей копились
бы бессрочно, хотя владелец обещал месяц, решение Р95.

Что сделано на машине 51.250.1.97:
- скрипт положен в /home/ubuntu/lena-inworld/chistka-zapisey.sh, слепок сходится
  с хранилищем побайтово, содержимое не правил ни на букву;
- расписание /etc/cron.d/lena-chistka-zapisey — раз в сутки 01:40 по времени
  сервера, это 04:40 по Москве, от пользователя ubuntu;
- рядом положен сторож скрипта proverka-chistki.sh, 27 проверок, зелёный там же.

Что добавлено в хранилище:
- bots/lena-golos/chistka-zapisey.cron — эталон расписания. На сервере имя без
  точки: файл с точкой в имени cron молча пропускает;
- bots/lena-golos/proverka-raspisaniya.sh — новый сторож на 17 проверок. Он
  проверяет не скрипт, а живёт ли уборка на машине;
- README.md — раздел про уборку на сервере.

Доказано замерами, не рассуждением:
- уборка удаляет файл, принадлежащий asterisk, запускаясь от ubuntu — подставной
  состаренный файл, в журнале «удалено 1»;
- заготовки chelovek, imya.txt и подпапки не тронуты, хотя старше срока;
- проход по расписанию оставил строку, которой рука не писала;
- пустой проход тоже оставляет след «удалено 0»;
- живых записей было 86 и осталось 86 — слепок списка совпал побайтово;
- текст расписания на сервере и в хранилище один и тот же.

Сторож показан красным четырьмя ножами. Четвёртый вскрыл дыру в самом стороже:
он искал по тому же шаблону имени, по которому чистит уборка, и звук с чужим
именем был невидим им обоим. Дыра закрыта отдельной проверкой.

Осталось открытым: сторожа никто не зовёт сам; расшифровки разговоров в
most.log уборка не касается — это следующий круг.
2026-08-06 06:04:11 +03:00
Дмитрий cd407855fc fix: срок хранения записей Лены — решение владельца Р95, месяц вместо заглушки 72 часа
Владелец назначил срок хранения звуковых записей во временной папке робота:
месяц (30 суток = 720 часов), решение Р95 от 05.08.2026 в
docs/grilling/2026-08-03-metodika-lena-pod-klienta.md. Столько же уже решено
хранить звук в самом портале (Р40, Т70) — одно число на оба места.

chistka-zapisey.sh: заглушка "число временное, назначит владелец" заменена
на текст решения; дописано предупреждение, что суммарно голос человека
может пролежать у нас до двух месяцев (месяц во временной папке плюс месяц
в портале).

proverka-chistki.sh: свои короткие 72 часа для проб помечены явно как не
боевой срок; добавлен сценарий 6 — прогон БЕЗ переопределения переменной,
который проверяет настоящий default (месяц) на границе 29/31 суток.

README.md: срок и решение владельца названы поимённо, добавлено то же
предупреждение про два месяца суммарно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:19:55 +03:00
Дмитрий a3b691d437 docs: сверка настройки Лены больше не врёт про сервер и не обещает несосчитанных чисел
Раздел «Как сверить копию настройки с сервером» в bots/lena-golos/README.md.

Что было не так:
- фраза «выше [from-mango] на сервере служебные разделы Asterisk» выдумана.
  Замер 05.08.2026: там одна пустая строка и больше ничего, а сверка верхушку
  файла не видела вовсе — любой чужой раздел выше прошёл бы молча;
- проверка выключателя обещала ответ 3, но grep -c считает и пояснения тоже:
  по копии выходит 5. Правильно перенесённую работу прочли бы как беду;
- врезка обещала расхождение «ровно две строки», хотя копия от [from-mango] —
  126 строк против 107 серверных.

Что стало:
- проверок три, вместе они накрывают весь серверный файл, слепого места нет;
- новая проверка верхушки: непустых строк выше [from-mango] сегодня 0;
- счёт выключателя срезает комментарии, ответы 0 и 3 сосчитаны командой;
- врезка сохранена, объёмы в ней замерены, точный список расхождений не обещан.

Все обещанные ответы прогнаны по макетам серверного файла, сторож проверен и
красным тоже. На сервер не ходили ни одной командой.

Отчёт — docs/superpowers/priyomka/stroyka-1/z-0-2-pochinka-sverki-2026-08-05.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:45:54 +03:00
Дмитрий f7cc8c8bce fix: чистка записей молча ругалась при недоступном журнале
Глушилка ошибки стояла хвостом после ">>" и не работала: оболочка разбирает
перенаправления слева направо, и ругань про недоступный журнал успевала
вылезти раньше. Чистка не падала, но в почту расписания сыпался мусор.
Лечение - глушилка на группе.

Сторож дорос до 23 проверок: добавлены папка из переменной окружения и
недоступный журнал. Изъян показан красным на старом варианте.

Отчёт З-0-2 дописан целиком: полный прогон 4930 проверок, красных 0,
код 0; зовущие поимённо; открытые вопросы владельцу.
2026-08-05 17:25:04 +03:00
Дмитрий 09c42a956c feat: выключатель записи разговоров Лены и чистка записей по сроку
Записи разговоров копились в /tmp/lena-inworld бессрочно, включая голоса
посторонних людей с живых входящих звонков. Три вещи:

1. bots/lena-golos/extensions-lena.conf - копия настройки телефонии с
   сервера, номер замаскирован намеренно. Настройка жила только на сервере
   и дважды молча откатывалась - нужен эталон для сверки.
2. Выключатель записи: раздел globals со строкой ZAPIS_RAZGOVOROV и обе
   строки записи через ExecIf. Гасит обе Лены сразу. Запись не является
   условием разговора: при ложном условии управление идёт дальше на
   AudioSocket. Незнание = не пишем.
3. bots/lena-golos/chistka-zapisey.sh - чистка по сроку. Папка и срок
   задаются доводом или переменной. Заготовки chelovek*, imya.txt и
   подпапки не трогает никогда - два независимых замка. Пустой прогон
   тоже оставляет след. Срок 72 часа временный, назначает владелец.

Сторож bots/lena-golos/proverka-chistki.sh: 19 проверок на своей временной
папке, показан красным четырьмя разными поломками лечения.

На сервере выключатель ПОКА НЕ ПРИМЕНЁН - это записано в README.
2026-08-05 17:14:22 +03:00
Дмитрий ad816b2571 feat(телеграм-реклама): сторож живой сессии кабинета запущен по расписанию
Вход в кабинет МТС умер молча 03.08.2026: робот не мог ни подать объявление, ни
прочитать вердикт, ни дойти до кассы, а владелец не узнал об этом ниоткуда. Сторож
сессии (bin/keepalive.js) в проекте был написан ещё 02.08, но нигде не запускался —
письмо физически не могло прийти. Третий за сутки случай «сторож есть, сработать не
может».

Заведена задача Планировщика Liderra-MTS-Keepalive: раз в час, скрытно (S4U — как у
самого робота), лимит 20 минут, повторный запуск поверх идущего запрещён.

Зовёт обёртку bin/keepalive-task.ps1, а не keepalive.js напрямую, по одной причине:
робот и сторож делят ОДИН профиль браузера. Приди сторож в момент, когда робот везёт
кампанию, — он либо помешал бы роботу, либо прислал владельцу ложное «вход слетел»
(ровно тот шум, который убирали правкой 02.08). Обёртка ищет браузер, поднятый с этим
профилем, и при занятости молча пропускает круг. Всё пишется в keepalive.log —
сторож, о котором ничего не известно, не сторож.

Обе половины доказаны вырезанием, а не объявлены:
  профиль свободен → «код 0 :: ALIVE»;
  профиль занят (держали браузером специально) → «ПРОПУСК: кабинет занят роботом».
Затем задача запущена через сам Планировщик — результат 0, в журнале снова ALIVE.

Ловушка на будущее: .ps1 с кириллицей обязан иметь метку кодировки (BOM). Без неё
Windows PowerShell 5.1 читает файл как ANSI и падает на разборе, жалуясь совсем не на
то место — «Missing closing '}'».
2026-08-03 17:36:44 +03:00
Дмитрий de7ea730d3 fix(телеграм-реклама): пересдача кампании была невозможна — чиним вход в мастер и прошедшие даты
Живой прогон на отклонённой кампании 2231134 (03.08.2026) вскрыл две поломки, из-за
которых пересдача не работала вообще:

1. openResubmitEditor требовал, чтобы «Исправить» открыло шаг «Сообщение». Кабинет
   открывает «Стоимость» — робот падал на входе. Теперь шаг ЧИТАЕТСЯ из адреса
   (wizardStepFromUrl), а не предполагается, и робот возвращается на «Сообщение»
   кнопкой «Назад» (goBackToStep): правки текста/ссылки/ОРД живут только там, без
   возврата пересдали бы кампанию БЕЗ исправлений и получили тот же отказ.

2. У отклонённой кампании расписание остаётся прошлым, и кабинет не пускает дальше:
   «Измените дату начала, выбранная уже прошла». Кнопка «Продолжить» при этом АКТИВНА
   и нажимается — робот молча стоял до тайм-аута, а наверх уходило невнятное «не
   перешли на /confirmation». submitBudget теперь сам переставляет даты на будущие
   (fixPastDatesIfNeeded) и проверяет результат по полям ввода; уход дат в следующий
   месяц — громкая ошибка, а не догадка (клик по клетке выбирает день показанного
   месяца).

Тесты: новые чистые функции (wizardStepFromUrl, isPastStartDateMessage,
datesForRestart) покрыты и доказаны вырезанием — сломанная правка красит тест.
Весь набор робота: 168 зелёных.

Приёмка живьём на 2231134: «Исправить» → budget → возврат на message → budget →
confirmation, остановка ДО отправки на модерацию. Ни рубля не потрачено.

Заодно там же прочитана касса (шаг /payment, без единого клика): подпись «Сумма к
оплате» существует, значение 201,6 ₽, parseCost даёт 201.60 — договор чтения
стоимости подтверждён живьём впервые.
2026-08-03 17:12:36 +03:00
Дмитрий 51ca003c9f docs+код: голосовая Лена — промт следующей смене и мост в репозитории
Промт смене 3 отменяет часть смены 2: там мозг Claude и одна Лена,
здесь GPT-4.1 и две — обзвонщица на 8090, приёмщица на 8091.

Мост most.py до сих пор жил в одном экземпляре на сервере, а сервер
03.08 дважды МОЛЧА откатил его к вчерашней версии. Второй откат
обесценил целое сравнение мозгов: три «разных мозга» оказались одним
Claude. Причина не найдена, поэтому рядом лёг сторож — сверяет слепок
раз в 10 секунд и записывает, кто был рядом в момент подмены.

Датчик тишины меряет мёртвый воздух по записи, а не по журналу: в
журнале пауза считается от решения поставщика «человек договорил»,
и время на само это решение не видно вообще.

Ключа Inworld ни в одном файле нет — мост читает его из ~/lena-inworld/.kluch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:06:52 +03:00
Дмитрий f70527c3df fix,деньги: телеграм-реклама переведена на рекламный кошелёк с заморозкой — робот наконец платит МТС
🔴 Найдено чтением кода, а не по памяти: робот НИКОГДА не оплачивал кампанию.
finalize доводил боевой запуск до кассы кабинета и уходил (launched:false,
stoppedAt:'payment'), денежные кнопки ему были запрещены наглухо, а «реальная
оплата» отложена на «Сессию 6», которой не случилось. Песочницу при этом
выключили 02.08 в 07:40.

Итог на бою: клиент жал «Запустить» → у него списывалась ВСЯ смета по размеру
списка → в кабинете оставался неоплаченный черновик → на модерацию он не уходил
→ реклама не показывалась ни разу → деньги не возвращались никогда (статус
draft_ready терминальный, возврата не имеет).

То есть беда была не «клиент переплачивает разницу», как записали накануне, а
«клиент платит сто процентов ни за что». Слепки установленного у владельца
робота совпали с веткой до буквы — на боевой машине тот же код.

Владелец решил: боевой не трогать (стоит как стоит), роботу денежную кнопку
разрешить, но с потолком.

── Робот теперь платит ────────────────────────────────────────────────────────

submitWithPayment на шаге /payment: сперва ЧИТАЕТ сумму к оплате, потом гонит её
через гейт против меньшего из двух потолков (лимит кампании и общий потолок
робота), и только потом ищет кнопку и жмёт. Сумма не прочиталась — не платим:
не знаем, что списываем. Не ушли со /payment после клика — падаем громко, портал
по отказу отпустит заморозку.

Общий чёрный список денежных кнопок НЕ ослаблен: та же кнопка остаётся запретной
для всех прочих путей, включая пересдачу. Разрешение точечное.

Прочитанная сумма — это НАШИ расходы у МТС; она едет в портал полем
actualCostRub, которое до сих пор было пустой заготовкой.

── Кошелёк вместо общего баланса ──────────────────────────────────────────────

Порядок зеркалит сам МТС (билинг снят живьём 27–28.07, FLOW-FINDINGS «Задача
2.0»: кабинет резервирует сумму, окончательно списывает по факту показов,
остаток возвращает):

  запуск          → морозим смету на ad_wallets (канал telegram)
  робот заплатил  → фактическая сумма легла в кампанию (mts_cost_rub, v9.66)
  модерация «да»  → списываем по факту × наценка, остаток отпускаем
  модерация «нет» → отпускаем всё, ни рубля не списано
  сбой до кабинета → отпускаем всё

Заморозка была убрана 29.07 намеренно — тогда рассуждали «сумма известна в момент
запуска, морозить нечего». Рассуждение верно ровно до вопроса владельца: сумма
известна, а сколько человек из списка вообще есть в телеграме — нет.

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

Идемпотентность больше не самодельная: бронь уникальна по кампании, списание — по
ключу события. Прежнее «сальдо проводок» стало не нужно, CampaignChargeServiceTest
удалён — его предмет (charge/refund по общему балансу) больше не существует,
замена KoshelekTelegramaTest.

── Экран ──────────────────────────────────────────────────────────────────────

Карточка денег показывала общий баланс портала. После переезда это стало прямым
враньём: клиент видел бы «денег хватает» там, где запуск отвечает 409. Теперь
ручка отдаёт СВОБОДНЫЕ деньги кошелька (баланс минус заморозка) и заморозку
отдельной графой, а кнопка пополнения ведёт в рекламный кошелёк — прежняя клала
бы деньги в общий баланс, и запустить рекламу всё равно было бы нельзя.

── Проверено вырезанием, а не только зелёным ─────────────────────────────────

- убрал запрет «сумма не прочитана» — покраснели 2 датчика оплаты;
- вернул списание по смете вместо факта — датчик поймал 315 ₽ там, где должно
  быть 210 ₽.

Замеры: телеграм-модуль 330/330, вместе с рекламой и СМС 1078/1078, экраны
251 файл / 2033 теста / 0 падений, робот 160/160, статанализ 0, типы 0, формат чисто.

Сторож денег под боевой ролью переписан: класс «тихий ноль» закрылся сам —
AdWalletService ставит контекст клиента сам, а не надеется на вызывающего.

🔴 На боевой НЕ выкачено. Осталось открытым: показать клиенту строкой
«заморожено / списано по факту / возвращено» на карточке кампании; пересдача
по-прежнему шлёт «без оплаты» (кампания вернётся на модерацию неоплаченной);
числа 0,720 и 0,816 за показ с медиа так и не замерены живьём.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:31:21 +03:00
Дмитрий b7ad9e0a66 merge: телеграм-реклама второй смены сведена в рабочую ветку перед выкатом СМС
Замер боевого перед выкатом СМС-модуля показал мину: на liderra.ru сейчас
работает код ветки fix/tg-zagolovok-obyavleniya — 33 записи телеграм-рекламы,
которых не было ни в main, ни в рабочей ветке. Сверено слепками файлов:
config/client_tg.php и TelegramTariffService.php на бою совпадают с их версией
и расходятся с нашей. Экраны портала собираются одним куском, поэтому выкат
СМС в прежнем виде откатил бы их живую рекламу назад. Решение владельца —
сперва забрать их работу к себе.

Разрешено пять склеек, все — сохранением обеих сторон:
- Tariff.php: их пояснение про закупочную цену плюс наша строка для подсказчика
  типов;
- phpstan-baseline.neon: взята наша вычищенная версия. Их 260 строк заметания
  не возвращены — статанализ после сведения дал 0 без них, потому что чинили мы
  не baseline, а сам механизм, и он вылечил их новые тесты тоже;
- advertising-telegram-view.spec.ts: наш типизированный мок оставлен;
- CHANGELOG_schema.md: столкновения номеров НЕ было — у нас v9.33/v9.34, у них
  v9.64/v9.65. Обе пары сохранены, ничего не двигали; в файл дописано, что дыра
  v9.35–v9.63 это след их завышенного замера, а не потерянные записи;
- STATUS.md: служебный файл наблюдателя, взят свежий.

Что сведение вскрыло дополнительно:
- их сторож значков поймал НАШИ три имени с экранов СМС — mdi-file-sign,
  mdi-account-badge-outline, mdi-file-download-outline в карте Lucide
  отсутствовали и на бою рисовались бы вопросом в кружке. Добавлены по смыслу;
- проверка типов фронта: 1 ошибка стала 0. Образец имени отправителя в тесте
  админки не задавал два поля, и Partial подмешивал в них undefined.

Проверено после сведения: PHP 4614 тестов, 4610 зелёных, 4 пропущено,
0 падений; экраны 250 файлов, 2018 зелёных, 3 пропущено, 0 падений;
статанализ 0 через composer stan; проверка типов 0.

Унаследованное, не мной внесённое и не тронутое: линтер фронта показывает
1 замечание на неразрывный пробел в advertising-sms-view.spec.ts — знак там
нужен по смыслу проверки, было до сведения.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 01:16:23 +03:00
Дмитрий 36f5093692 merge: общая ветка с клиентским СМС-модулем сведена в рабочую
Общая ветка переведена вперёд на ветку клиентских СМС — перемоткой, без
слияния, поэтому конфликтов там быть не могло. Затем общая сведена в рабочую
ветку, которая отставала на 79 записей.

Столкновение было одно и знакомое — словарь орфографии cspell-words.txt.
Разрешено правилом «обе стороны настоящие»: слова обеих веток сохранены,
ничего не выброшено. Журнал схемы БД на этот раз свёлся сам, столкновения
номеров не было.

СТОРОЖ ПДн ОСТАНОВИЛ ЗАПИСЬ И БЫЛ ПРАВ. В образце базы номеров, который
клиент скачивает перед своей первой рассылкой, стоял рабочий телефон
владельца — в двух видах. Это не утечка чужих данных: тот же номер публично
опубликован в реквизитах ИП по требованию ЮKassa. Но клиент заполняет этот
файл своими номерами и запускает рассылку — забытая строка означала бы СМС
владельцу за деньги клиента. Заменён на выдуманный из тестового диапазона.

Подсказки на экране рассылок («+7 999 123-45-67») выдуманы изначально;
разрешены в .gitleaks.toml ПО ЗНАЧЕНИЮ, а не по файлу, чтобы сами файлы
остались под охраной. Сторож проверен вырезанием: подложенный номер, не
подпадающий ни под одно разрешение, он поймал; после снятия подложки — чисто.

Служебный счётчик наблюдателя убран в тайник на время сведения и возвращён
после — он машинный и пересоздаётся хуками.

Файлы второй смены, которая работает в этой же папке, не тронуты и в
слияние не попали.

Проверки сведённой ветки: тесты 4533, зелёных 4529, падений нет; экраны
238 файлов и 1906 зелёных, сторож сети не сработал ни разу; статанализ 0;
формат чист.
2026-08-02 23:27:13 +03:00
Дмитрий 9e88047abd fix: канал МТС отправляет — вторая причина молчания найдена
Х-5 закрыт, но с третьего захода. Причин было две.

Первая — не подключён тариф, найдена днём и верна. Вторая нашлась только
поздним вечером: имя отправителя liderra.ru подключено ТОЛЬКО НА ОДНОГО
оператора, а все пробы десять дней слались на номер владельца, который на
Tele2. МТС принимал сообщение, видел чужого оператора без согласованного
имени и молча клал в NotSent с пометкой «тип трафика не определён».
Прибор был негодным изначально, и это не заметили ни разу.

Доказано вырезанием: одно сообщение на живой номер МТС тем же боевым путём —
44850834, статус Sending, кабинет пишет «Отправлено». Больше ничего не меняли.

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

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

Осталось доспросить окончательный статус сообщения 44850834, чтобы увидеть
поле cost — единицы цены оператора. МТС хранит статусы трое суток, то есть
до 05.08.

В браузерную обвязку добавлена возможность держать ОДНО окно кабинета
открытым между шагами: _hold.mjs поднимает окно и живёт, _step.mjs цепляется
к нему и не закрывает. Кабинет — тяжёлый SPA, и открытие браузера на каждый
шаг стоило минуты.
2026-08-02 22:05:46 +03:00
Дмитрий 15eadfde63 merge: подтянул общую ветку в клиентские СМС — 4 записи отставания закрыты
Общая ветка принесла правки по разведке Яндекса: список рисует только видимые
строки, упавшее задание больше не сгорает, у придержанного объявления не бывает
окна причины, плюс четыре промта смен 01-02.08. СМС-кода эти правки не касаются.

Столкновение одно — словарь орфографии: обе стороны дописали слова в конец.
Обе стороны настоящие, склеены обе, ни одно слово не выброшено.

Проверки после сведения: статанализ 0, форматирование СМС и рекламы passed,
прогон 423 теста и все зелёные — тесты рекламы, которые принесла общая ветка,
плюс весь модуль клиентских СМС.

Ветка по-прежнему в main НЕ влита, не пушена, на боевой не выкачена.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:43:30 +03:00
Дмитрий deb54ae1a4 fix(телеграм-робот): чиним саму осечку — робот путал «не отрисовалось» с «выкинуло»
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Прошлая правка лечила последствие: временный отказ возвращал задание в очередь.
Причина осталась. Она была в одной строке src/session.js:

    await gotoStable(page, config.cabinetUrl, config);   // ответ ВЫБРАСЫВАЛСЯ

gotoStable уже возвращал признак «страница так и не ожила» — его игнорировали,
а дальше отсутствие меню объявляли разлогином. Отсюда ложное «Вход слетел»
на живой сессии (приёмка 02.08, 1 прогон из 2).

Теперь checkSession различает четыре разных случая:
- in             — меню кабинета на месте;
- out            — видна страница входа: сессии правда нет. Повтор бесполезен,
                   сдаёмся СРАЗУ с «нужен вход руками» (retryable=false);
- ne-otrisovalos — ни меню, ни формы входа: SPA завис. Три попытки с паузой 5с,
                   потом временный отказ (retryable=true);
- zablokirovan   — «Доступ … запрещён»: МТС не пускает адрес. Сдаёмся сразу,
                   со своей внятной причиной (retryable=false).

Пустую страницу про меню не спрашиваем вовсе — минус 10 секунд на попытку.

Побочно вылечен сторож входа bin/keepalive.js: он болел тем же и слал владельцу
письмо «Вход в кабинет слетел» на каждом зависании кабинета. Теперь в письме
стоит настоящая причина, а временная беда не считается падением сторожа.

Смена смысла: SessionLostError раньше был retryable=true (правка часом раньше).
Теперь настоящий разлогин НЕ повторяется — повтор его не лечит, нужен человек.
Повторяется только зависшая страница.

Тесты писались красными: робот 143/143 (+9 новых, новый файл session-check),
портал 254/254 без изменений — кода портала правка не касается.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:45:02 +03:00
Дмитрий 3c72560139 fix(телеграм): ложное «вход слетел» больше не хоронит кампанию клиента
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Приёмка Г1 02.08.2026 прошла: задание прошло насквозь, робот создал кампанию
2234762 в кабинете МТС, заголовок объявления и ссылка проверены ГЛАЗАМИ на шаге
«Объявление». Песочница, 445 номеров из 450 опознано, деньги не двигались.
Следы пробы убраны, копия — app/storage/app/proba-priemki-2026-08-02.json.

Приёмка вскрыла мину. Первое из двух заданий упало с «Вход слетел — нужен
повторный логин», хотя вход был ЖИВ: тем же кодом робота минутой позже проверка
ответила «да». Кабинет МТС завис на пустой странице, робот принял это за
разлогин. Цена — одна осечка сразу хоронила кампанию: задание в failed,
кампания в failed, второй попытки нет. На приёмке это 1 прогон из 2.

Робот (bots/mts-telegram-ads):
- ensureLoggedIn в src/session.js — три попытки с паузой 5с; сбой самой проверки
  считается попыткой, а не падением;
- SessionLostError несёт признак retryable;
- runner.js во всех трёх режимах (запуск, чтение вердикта, пересдача) зовёт
  ensureLoggedIn и протаскивает retryable в отчёт;
- bin/poll.js протаскивает retryable из своей обёртки.

Портал (app):
- RobotResult читает retryable (только при ok:false);
- TgRobotController::done на временный отказ возвращает задание в очередь
  (queued, taken_at=null) и кампанию НЕ трогает, пока attempts < max_attempts;
- client_tg.robot.max_attempts, по умолчанию 3, env TG_ROBOT_MAX_ATTEMPTS.

Считаются выдачи задания, а не отказы: тем же счётчиком attempts пользуется
возврат по сроку аренды, поэтому бесконечного круга не выйдет.

Тесты писались красными: портал 254/254 (720 проверок, +4 новых),
робот 134/134 (+4 новых). Pint чист, статанализ 0 ошибок.
В phpstan-baseline.neon дописаны 27 записей про $this в Pest-замыканиях —
тем же порядком, что у соседнего TgRobotDoneTest.

На боевой НЕ выкачено.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 02:00:46 +03:00
Дмитрий b6a15c5bbf merge: подтянул общую ветку в клиентские СМС — 227 записей отставания закрыты
Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.

Девять столкновений, каждое разобрано по существу.

Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.

Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.

Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.

Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.

Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.

СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.

Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
  - 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
    перенесены из рабочей ветки, где владелец их уже принял;
  - остальные 42 - свои, в новом коде ветки, и починены по существу:
    задвоенный ключ массива в трёх тестах (след копирования - комментарий
    оторвался от своей строки), врущие описания двух помощников (PHP сам
    делает из ключа-номера число), сужение типа возврата, прятавшее от
    анализатора свойства подставного отправителя, лишний знак вопроса и
    четыре бесполезных перенумерования списка.
  - Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
    поимённо и покраснел; убрал - ноль.

Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.

Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.

Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.

Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.

NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:50:41 +03:00
Дмитрий 39f1d77895 fix разведка Яндекса: список рисует только видимые строки + упавшее задание больше не сгорает
Две мины, обе вскрыты замерами, обе роняли разведку молча.

1. СПИСОК ОБЪЯВЛЕНИЙ ВИРТУАЛИЗОВАН — «объявления нет» бывало ложью.

   Вчерашний разбор §7.10 утверждал, что семь объявлений кампании 713175197 пропали
   из кабинета. Это неверно. Три замера подряд:
     - ads.get боевым ключом вернул ВСЕ 15, одна кампания, одна группа, State=OFF;
     - разметка страницы держит 8 строк;
     - сам кабинет отвечает своему списку "adsCount": 15, и в этом же ответе лежат
       «пропавшие» номера.

   Список рисует только видимые строки, и у него СВОЯ полоса прокрутки: колесо мыши
   по странице его не двигает — двенадцать оборотов не сдвинули ничего, и это ровно
   та проверка, что породила слова «прокрутка их число не меняет».

   Лечение — dolistatDoObyavleniya: не дождались строки, крутим ящик списка шагами
   по 600 точек, после каждого смотрим снова. Останов — нашли, упёрлись в дно или
   кончились 25 шагов. Только после этого доклад «объявления в списке нет» — правда.

   Приёмка глазами настоящим readRejection по живому кабинету:
     17787641406 и 17787641505 из «пропавших» — принесли настоящую причину;
     17787641506 — прежние 760 знаков через окно, старая дорога цела.

2. УПАВШЕЕ ЗАДАНИЕ РАЗВЕДКИ СГОРАЛО НАВСЕГДА.

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

   Теперь сбойное дублем не считается: поднимаем ТУ ЖЕ строку обратно в очередь,
   счётчик попыток копится, потолок — три попытки. Строки в работе и успешно
   закрытые не трогаем.

Сторожа: три новых на постановку заданий, два на долистывание. Третий сторож проверен
вырезанием — покраснел ровно на убранной защите. Статанализ ноль, тесты рекламы 354/354,
робот 88/88, орфография и разметка чисты.

Урок дороже самой починки: доклад робота — не замер. Ложная фраза робота за один шаг
стала ложным выводом о продукте и уехала в разбор и в промт следующей смене.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:27:07 +03:00
Дмитрий 97a13aef64 fix разведка Яндекса: у придержанного объявления окна причины не бывает
Accessibility (Pa11y live) / a11y (push) Has been cancelled
Первый боевой обход с починками модерации поставил разведке 13 заданий
на придержанные объявления кампании 713175197 — и все 13 упали. Разбор
руками в живом кабинете дал две причины.

1. Семи объявлений из пятнадцати в списке кабинета нет вовсе — это хвост
   от неудачных перезаливок креативов 29.07. Робот докладывает «объявления
   нет», и он прав. Чинить тут нечего, но в базе портала эти семь строк
   висят как живые — разбираться отдельно.

2. У остальных шести клик по ячейке статуса не открывает НИЧЕГО. Проверено
   руками: ячейка есть, кликается, окно не появляется за 20 секунд, и на
   странице после клика нет ни одного элемента с popup в разметке. Робот
   падал сырой ошибкой Playwright.

   И раскрывать было нечего: вся причина написана прямо в списке —
   «Для показа в заданных регионах предоставьте документы». У отклонённого
   объявления ровно наоборот: в списке пусто, суть спрятана в окне. Робот
   писался под второй случай и на первом падал, имея причину перед глазами.

Лечение: надпись из списка забираем ДО клика. Окна нет — смотрим, есть ли
в надписи что-то кроме общих слов кабинета. Есть — это и есть доклад; одни
общие слова — сдаёмся, как раньше, и объявление уходит человеку в «ждёт
разбора». Выдумывать по-прежнему нельзя ничего.

Замеры. Сторож написан ДО починки и падал. Полный набор робота 86 из 86.
Приёмка глазами по живому кабинету, три задания подряд: придержанное
принесло настоящую причину (раньше падало), отклонённое — прежний полный
текст про предупреждение о финансовых услугах, отсутствующего робот честно
не выдумал.

Разбор с дословными подписями — cabinet-flow.md §7.10.

🪤 На будущее: 13 упавших заданий сами не переиграются — защита от дублей
не пускает второе задание на то же объявление независимо от его состояния.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:59:51 +03:00
Дмитрий 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