docs телеграм: замысел и план кнопки «Остановить рекламу»

Кнопка остановки идущей рекламы + закрытие кампании по слову кабинета «Завершена».
Прежний запрет на нажатие «Завершить» снят владельцем, разведка проведена живьём:
кабинет отвечает «Останавливается, до 30 минут», закончив пишет «Завершена» и сам
возвращает неоткрученный остаток целиком (+171,84 ₽ из 182,40 на кампании 2237821).

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

Разведка кода вскрыла три мины, все зафиксированы в замысле:
1. читалка собирает весь текст ряда вместе с подписями кнопок, а у идущей рекламы
   в ряду стоит кнопка «Завершить» — совпадение по корню закрывало бы работающую
   рекламу и возвращало деньги за то, что крутится прямо сейчас;
2. очередь заданий роботу считает одинаковыми любые два задания по одной кампании —
   просьба остановить при висящем чтении цифр молча терялась бы;
3. та же подстрока губительна и для опознавателя ряда, не только для разбора.

План: 11 задач, каждая сперва красным сторожем. Кода не трогает — только документы.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-08 20:03:28 +03:00
parent 7859eada91
commit fa487dfd29
2 changed files with 2364 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
# Телеграм: кнопка «Остановить рекламу» и закрытие по слову кабинета
**Дата:** 08.08.2026
**Повод:** владелец сказал «делай кнопку». Прежний запрет на нажатие «Завершить» в кабинете
МТС снят ещё утром, разведка проведена живьём — знаем и слово кабинета, и что происходит с
деньгами.
## Что уже известно из разведки (не гипотезы — замеры 08.08.2026)
Кнопку «Завершить» нажали на живой кампании портала №15 (в МТС `2237821`):
| Что | Ответ |
|---|---|
| Слово кабинета для законченной кампании | **«Завершена»** |
| Слово, пока идёт остановка | **«Останавливается»** + «Это займёт до 30 минут» |
| Реклама встала? | Да: показы застыли на 22, расход площадки 10,56 ₽ |
| Баланс кабинета до / после | 4 812,51 ₽ → **4 984,35 ₽** |
| Вернулось от площадки | **+171,84 ₽ — ровно `182,40 10,56`** |
| Кнопки у завершённой строки | только «Посмотреть настройки» — запустить заново нельзя |
🔑 **Кабинет отдаёт неоткрученный остаток целиком и сам, без просьбы.** Значит наш возврат —
про деньги КЛИЕНТА, а не про деньги площадки: их МТС уже вернул.
Первоисточник и хронология — план
[2026-08-08-telegram-konec-reklamy-i-vozvrat.md](../plans/2026-08-08-telegram-konec-reklamy-i-vozvrat.md).
## Решения владельца
1. **Деньги возвращаем, когда кабинет скажет «Завершена»**, а не в момент нажатия. До
получаса реклама ещё крутится и тратит; вернуть сразу — вернуть лишнее из своего кармана.
2. **Кнопка только у запущенной рекламы.** Есть ли «Завершить» у той, что на проверке или
ждёт старта, — НЕ замерено. Выдумывать не будем. До запуска у клиента и так есть «Отменить».
## Что делаем
### Ч1. Портал узнаёт конец рекламы от кабинета
Это фундамент: без него кнопка была бы враньём — портал сказал бы «остановлено», а деньги
вернулись бы только через трое суток по правилу «встала».
**Робот учится двум словам.** `parseModerationStatus` в `bots/mts-telegram-ads/src/cabinet.js`
знает шесть слов и ни одного из наших. Добавляем два новых канонических значения:
- `completed` — кабинет написал «Завершена»;
- `stopping` — кабинет написал «Останавливается». Это ещё НЕ конец: показы идут, деньги не
вернулись.
🔴 **Мина в подстроках — вскрыта при разведке кода, стоит денег.** Читалка собирает ВЕСЬ текст
строки, включая подписи кнопок, а у запущенной кампании в строке живёт кнопка **«Завершить»**.
Совпадай мы по корню `заверш` — каждая идущая реклама читалась бы как законченная, портал
закрывал бы её и возвращал клиенту деньги за рекламу, которая крутится. Сравниваем только по
полным словам `завершена` / `завершено`, и точно так же `останавливается`, а не `останов`.
Красный сторож на это обязателен: строка с кнопкой «Завершить» и статусом «Активна» обязана
читаться как `approved`.
Порядок проверок: новые слова идут СРАЗУ после «отклонен», до `одобрен|активн|запущен`
в строке-блобе рядом могут стоять оба, и побеждать должно последнее по времени состояние.
**Робот передаёт сырой текст.** Сейчас `readCampaignStats` отдаёт только разобранное слово, а
исходный текст строки выбрасывает. Из-за этого запись в журнале «слово незнакомое» приходит
БЕЗ самого слова, и следующее новое слово мы снова потеряем и снова полезем в кабинет глазами.
Добавляем в ответ `rowText` — склеенный текст строки, обрезанный до 300 знаков.
**Портал закрывает кампанию по слову.** `TelegramStatsApplier` сперва, как и сейчас, кладёт
свежие цифры, а затем: кабинет сказал `completed` → зовём `TelegramZavershenieService` закрыть
кампанию и вернуть переплату с пометкой `zavershena-kabinetom`. Порядок важен — считать возврат
надо по ТОЛЬКО ЧТО прочитанному расходу, а не по вчерашнему.
Прежние два правила (открутилась полностью / встала на трое суток) остаются подстраховкой на
случай, если кабинет снова сменит слово.
Расход неизвестен, а кабинет говорит «Завершена» — закрывать нечем: уходим на ручной разбор
существующим путём `nemaya`, а не закрываем молча с нулевым возвратом.
**Побочный выигрыш:** закрытие по слову работает для ЛЮБОЙ рекламы, а не только остановленной
руками. Отработавшая сама закроется в течение часа, а не через трое суток.
### Ч2. Кнопка
**Что видит клиент.** У запущенной рекламы в карточке — красная кнопка «Остановить рекламу».
Окно подтверждения говорит правду:
> Реклама остановится насовсем — запустить эту же обратно нельзя, только создать новую.
> Остановка занимает до 30 минут, всё это время показы ещё идут.
> Деньги за непоказанное вернутся на рекламный кошелёк, когда площадка закончит.
Подтвердил — кнопка сменяется надписью «Останавливается…», второй раз нажать нельзя.
**Что делает портал.** Новая ручка `POST /api/telegram/campaigns/{id}/stop`:
- пускает только `launched` с непустым `mts_campaign_id`, иначе 422;
- второе нажатие (отметка уже стоит) — 422, задания не плодим;
- ставит отметку `stop_requested_at` и кладёт роботу задание нового вида `stop`;
- **денег не трогает вовсе.**
🔴 **Вторая мина, тоже из разведки кода.** `TelegramRobotQueue::enqueue` возвращает уже
существующее незавершённое задание по кампании — ЛЮБОГО вида. Нажатие «Остановить» при висящем
задании на чтение цифр молча вернуло бы чужое задание: портал сказал бы «останавливаем», а
роботу никто ничего не передал бы. Чиним: одинаковыми считаем задания одного вида, а не любые
два по одной кампании. Двум заданиям в очереди ничего не грозит — робот и так берёт строго по
одному за раз.
**Что делает робот.** Новый вид работы `stop`:
1. открывает список кабинета и находит строку ИМЕННО этой кампании — тем же поиском строки,
что уже работает при чтении цифр (подъём вверх, пока в поддереве ровно один номер кампании);
2. ищет «Завершить» **внутри найденной строки**. 🔴 В разведке считали по всей странице и
требовали ровно одну — это было верно только потому, что запущенная кампания была одна. У
двух клиентов сразу так завершили бы чужую рекламу;
3. жмёт; в окне подтверждения жмёт только кнопку из белого списка слов — рядом в кабинете
живут кнопки удаления;
4. **перечитывает строку и доказывает, что слово сменилось** на «Останавливается» или
«Завершена». Кабинет обновляет строку не мгновенно — перечитываем несколько раз с паузой.
Не сменилось — честный отказ, а не «наверное получилось».
Отчёт робота — той же формы, что у чтения цифр (`readCampaignStats` вызывается для проверки),
плюс признак `stopped`. Разбирает его новый `TelegramOstanovkaApplier` — отдельная ветка в
`TgRobotController` рядом с уже существующими: цифры и слово он отдаёт готовому
`TelegramStatsApplier`, а сам занимается только отметкой. Значит портал применит и свежие
цифры, и слово: успел кабинет написать «Завершена» сразу — кампания закроется тут же, написал
«Останавливается» — закроется на ближайшем часовом заходе опросчика.
**Если не получилось.** Вход в кабинет протухает, это бывало.
Временный отказ (`retryable`) портал уже умеет возвращать в очередь сам, до всякого разбора
отчёта: задание уходит на повтор, отметка `stop_requested_at` остаётся, клиент по-прежнему
видит «Останавливается…». Робот попробует снова через минуту.
Отметку снимаем только на ОКОНЧАТЕЛЬНОМ отказе — когда попытки кончились
(`client_tg.robot.max_attempts`) или отказ невосстановимый. Тогда портал не врёт: кнопка
возвращается, в карточке появляется «Остановить не получилось, попробуйте ещё раз», и в журнал
падает запись уровнем `warning` — на боевом ниже не доезжает никогда.
## Что меняется в хранилище
Одна колонка: `client_tg_campaigns.stop_requested_at` (timestamptz, null) — «когда клиент
попросил остановить». Запись в журнале схемы — следующая свободная версия за v9.74 (ожидаемо
v9.75; номер занимать в последний момент, в эту же ветку коммитит соседняя смена, и столкновение
номеров журнала нельзя «разрешить» выбором стороны).
Нового статуса кампании НЕ заводим. Рассматривали — тянет за собой переходы, подписи на
экране, все места, где перечислены статусы, и опросчика цифр пришлось бы учить брать ещё и
его. Видимого выигрыша для клиента ноль, а мест соврать больше.
## Границы: чего тут НЕТ
- **Остановки рекламы, которая ещё на проверке МТС или ждёт старта.** Не замерено, есть ли
там кнопка и что она делает с замороженными деньгами. Решение владельца — не выдумывать.
- **Возврата денег в момент нажатия.** Решение владельца — ждать слова кабинета.
- **Запуска остановленной обратно.** Кабинет этого не даёт: у завершённой строки остаётся
только «Посмотреть настройки». Клиенту доступно «Повторить эту рекламу» — она создаёт новую.
## Сторожа
Каждый кусок — сперва красный сторож, потом код.
**Разборщик слов (робот):**
- строка с кнопкой «Завершить» и статусом «Активна» → `approved`, НЕ `completed` (мина №1);
- «Завершена» → `completed`; «Останавливается» → `stopping`;
- прежние шесть слов читаются как раньше.
**Очередь заданий (портал):**
- при висящем задании на чтение цифр просьба остановить создаёт ОТДЕЛЬНОЕ задание (мина №2);
- повторная просьба того же вида нового задания не создаёт.
**Ручка остановки:**
- не-`launched` → 422; без номера кабинета → 422; вторая просьба → 422;
- успешная просьба ставит отметку и кладёт задание; денег не двигает.
**Деньги:**
- слово «Завершена» закрывает кампанию и возвращает переплату один раз (ключ
`tg:campaign:{id}:vozvrat`); повтор второй раз не возвращает;
- слово «Останавливается» НЕ закрывает и денег НЕ трогает;
- «Завершена» при неизвестном расходе → ручной разбор, а не тихое закрытие с нулём.
**Робот, нажатие:**
- две запущенные кампании в списке — жмём в СВОЕЙ строке;
- слово после нажатия не сменилось → отказ, а не успех.
**Экран:**
- кнопка видна только у запущенной; после просьбы — «Останавливается…», нажать нельзя;
- отказ возвращает кнопку и показывает человеческую причину.
## Красные линии
1. **Кабинет МТС — боевой, там деньги.** Нажатие необратимо. Приёмка на живой кампании — только
с разрешения владельца и на той, которую не жалко.
2. **Робот живёт в двух копиях**`bots/mts-telegram-ads` в репозитории и рабочая
`C:\liderra\mts-telegram-robot`. Разойдутся — **сводить, а не копировать поверх**.
3. **Боевой прод liderra.ru — живые клиенты и деньги.** Выкат — отдельным шагом, только с
разрешения владельца, после зелёных сторожей.