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>
This commit is contained in:
Дмитрий
2026-07-28 16:43:58 +03:00
parent 2b51ae2754
commit d50a93e55c
8 changed files with 501 additions and 30 deletions
+37
View File
@@ -126,6 +126,43 @@ node bin/run.js --task task.example.json
`{"ok": true, "matched": 996, "launched": false}``matched` это сколько
номеров «не МТС» нашлось, `launched` — нажал ли робот кнопку запуска.
## 6.1. Пересдача отклонённой кампании (`mode: "resubmit"`)
Если модерация МТС **отклонила** кампанию, робот может её починить: войти в
редактор через «Исправить», внести исправления и снова отправить на модерацию
**без оплаты** (0 ₽ — деньги не списываются).
Что исправляем — на выбор (в ответ на причину отказа): приложить документ
модератору (лицензию/справку) и/или поправить текст/ссылку/категорию ОРД. Нужна
**хотя бы одна** правка — иначе тот же контент снова отклонят. Номера/бюджет не
нужны — они у кампании уже есть.
| Поле | Обязательное | Что это |
|---|---|---|
| `mode` | да | `resubmit` |
| `campaignId` | да | номер кампании, которую чиним (из кабинета) |
| `submitMode` | да | `draft` (дойти до конца, **не** отправлять) или `live` (отправить без оплаты) |
| `moderatorFile` | нет* | путь к документу для модератора (pdf/картинка) |
| `adText` | нет* | новый текст объявления |
| `buttonUrl` | нет* | новая ссылка (канал/бот/сайт) |
| `ordCategory` | нет* | новая категория ОРД |
\* хотя бы одно из полей-правок (`moderatorFile`/`adText`/`buttonUrl`/`ordCategory`) обязательно.
```json
{
"mode": "resubmit",
"campaignId": "2231134",
"submitMode": "live",
"moderatorFile": "screenshots/licenziya.pdf",
"adText": "Исправленный текст объявления"
}
```
`submitMode: "live"` реально отправляет кампанию на повторную модерацию (бесплатно) —
запускать **только с разрешения владельца**. Результат: `{"ok": true,
"campaignId": "2231134", "resubmitted": true}`.
## 7. Присмотр за входом (keep-alive)
Команда `node bin/keepalive.js` тихо проверяет, жив ли вход в кабинет. Если
+13 -2
View File
@@ -1,9 +1,9 @@
import 'dotenv/config';
import { readFileSync } from 'node:fs';
import { loadConfig } from '../src/config.js';
import { parseTask } from '../src/task.js';
import { parseTask, parseResubmitTask } from '../src/task.js';
import { createMailer, smtpTransport } from '../src/mailer.js';
import { runTask, runReadStatus } from '../src/runner.js';
import { runTask, runReadStatus, runResubmit } from '../src/runner.js';
const argIdx = process.argv.indexOf('--task');
const taskPath = argIdx === -1 ? undefined : process.argv[argIdx + 1];
@@ -28,6 +28,17 @@ if (rawTask.mode === 'read-status') {
process.exit(res.ok ? 0 : 1);
}
// Режим resubmit (R6, пересдача отклонённой кампании): вход «Исправить» → документ
// модератору → отправка без оплаты. Как read-status, идёт ДО parseTask (у пересдачи
// свои обязательные поля: campaignId/moderatorFile/submitMode, а не поля кампании).
if (rawTask.mode === 'resubmit') {
const task = parseResubmitTask(rawTask);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const res = await runResubmit(config, task, { timestamp });
console.log(JSON.stringify(res, null, 2));
process.exit(res.ok ? 0 : 1);
}
const task = parseTask(rawTask);
const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+175 -27
View File
@@ -80,6 +80,18 @@ export function isForbiddenButtonText(text) {
return FORBIDDEN_BUTTON_RE.test(text.replace(/\s+/g, ' ').trim());
}
// Кнопка БЕСПЛАТНОЙ отправки на модерацию (0 ₽) на шаге /payment: «Отправить на
// модерацию без оплаты» (подтверждено живьём A2, 28.07.2026 — баланс не тронут).
// Именно её целит submitWithoutPayment при пересдаче. Слово «оплаты» здесь НЕ
// денежная трата (см. FORBIDDEN_BUTTON_RE — основа «оплатит», а тут «без оплаты»).
const NO_PAYMENT_SUBMIT_RE = /отправить на модерацию без оплаты/i;
// Чистый предикат: текст кнопки — «отправить на модерацию без оплаты»?
export function isNoPaymentSubmitText(text) {
if (typeof text !== 'string') return false;
return NO_PAYMENT_SUBMIT_RE.test(text.replace(/\s+/g, ' ').trim());
}
// Ревью-фикс F8: сверка фактической стоимости кампании из кабинета с потолком.
// actualRub — строка из parseCost («201.60») или число; null/undefined/нечитаемо →
// сверять нечего (no-op; решение блокировать при нечитаемости — на вызывающем).
@@ -191,6 +203,58 @@ export async function readModerationStatus(page, config, mtsCampaignId) {
return { moderationStatus, reason };
}
// Вход в редактор ОТКЛОНЁННОЙ кампании через «Исправить» (robot-режим пересдачи,
// R3). Открывает список, находит СТРОКУ кампании по её id (scope как в
// readModerationStatus — по campaignHrefRe), нативным кликом жмёт «Исправить» в этом
// ряду (модалка-оверлей перехватывает мышиный клик, нужен .click() по DOM-элементу),
// дожидается редактора на шаге «Сообщение» (/telegram-a2p/{id}/message). Ряд/кнопка
// не найдены или не тот URL → падаем громко.
//
// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A2, 28.07.2026): вход «Исправить» кампании 2231134 открыл
// редактор сразу на /message с предзаполненными текстом/ссылкой/ОРД.
export async function openResubmitEditor(page, config, mtsCampaignId) {
const id = String(mtsCampaignId);
const alive = await gotoStable(page, config.telegramUrl, config);
if (!alive) throw new Error('Список кампаний не отрисовался (пустая крутилка кабинета)');
await humanPause(config);
const clicked = await page.evaluate((cid) => {
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
const byId = new RegExp(`telegram(?:-a2p)?/${cid}(?:\\D|$)`);
const anchor = [...document.querySelectorAll('a[href]')]
.find((a) => byId.test(a.getAttribute('href') || ''));
if (!anchor) return 'ряд-не-найден';
// Поднимаемся до контейнера ряда (грид из div — как в readModerationStatus).
let row = anchor;
for (let up = 0; up < 10 && row; up++, row = row.parentElement) {
if (/(отклонен|одобрен|активн|модераци|черновик)/i.test(norm(row.innerText))) break;
}
const scope = row || document;
const btn = [...scope.querySelectorAll('a,button,[role=button]')]
.find((n) => /^исправить$/i.test(norm(n.innerText)));
if (!btn) return 'кнопка-Исправить-не-найдена';
btn.click();
return 'clicked';
}, id);
if (clicked !== 'clicked') {
throw new Error(`Пересдача: не удалось войти в «Исправить» кампании ${id} (${clicked})`);
}
// Редактор открывается на шаге «Сообщение». Убеждаемся в правильном id и шаге —
// иначе (открылась чужая/не та кампания) НЕ трогаем её поля.
const urlRe = new RegExp(`telegram-a2p/${id}/message`);
try {
await page.waitForURL(urlRe, { timeout: 30000 });
} catch {
throw new Error(`Пересдача: после «Исправить» ожидали редактор /telegram-a2p/${id}/message, а URL = ${page.url()}`);
}
// Само-верификация: на шаге «Сообщение» есть поле текста объявления.
await page.locator('textarea[placeholder^="Расскажите о боте"]').waitFor({ timeout: 30000 });
await humanPause(config);
}
// Полный флоу визарда (подтверждён живьём 27.07.2026):
// Знакомство → Тип («Своя база») → /audience → /message → /budget → /confirmation
//
@@ -360,32 +424,49 @@ export async function submitAudience(page, config) {
// рекламодатель (наш ИП «Кондратьев Платон Дмитриевич, ИНН 245210851872») и
// маркировка ОРД подставляются кабинетом автоматически.
export async function fillAd(page, config, task) {
await page.locator('textarea[placeholder^="Расскажите о боте"]').fill(task.adText);
await fillAdText(page, config, task.adText);
if (task.mediaFile) await fillAdMedia(page, config, task.mediaFile);
await fillAdLink(page, config, task.buttonUrl);
await selectOrdCategory(page, config, task.ordCategory);
// Документ модератору при ПЕРЕСДАЧЕ (задача 3.6): без файла — шаг не трогаем.
if (task.moderatorFile) await attachModeratorDoc(page, config, task.moderatorFile);
}
// Текст объявления (textarea «Расскажите о боте…»).
export async function fillAdText(page, config, text) {
await page.locator('textarea[placeholder^="Расскажите о боте"]').fill(text);
await humanPause(config);
}
if (task.mediaFile) {
await page
.locator('input[type="file"][accept*="image"], input[type="file"][accept*="video"]')
.setInputFiles(task.mediaFile);
await humanPause(config);
}
await page.locator('input[placeholder^="Ссылка на канал"]').fill(task.buttonUrl);
// Медиа объявления (картинка/видео Premium) — отдельный от документа модератору
// file-input (accept image/video).
export async function fillAdMedia(page, config, mediaFile) {
await page
.locator('input[type="file"][accept*="image"], input[type="file"][accept*="video"]')
.setInputFiles(mediaFile);
await humanPause(config);
}
// Категория ОРД — единственное обязательное поле блока «Информация для ОРД».
// downshift-combobox: клик → очистить → ввод по буквам → дождаться опций →
// клик по опции. Синтетический ввод/выбор валидацию не проходит — нужен
// настоящий клик по элементу списка (подтверждено живьём: срабатывает с 1-й
// попытки; значение поля после выбора = выбранной категории).
// Ссылка «Что рекламируете» (канал/бот/сайт).
export async function fillAdLink(page, config, url) {
await page.locator('input[placeholder^="Ссылка на канал"]').fill(url);
await humanPause(config);
}
// Категория ОРД — обязательное поле блока «Информация для ОРД».
// downshift-combobox: клик → очистить → ввод по буквам → дождаться опций → клик по
// опции. Синтетический ввод/выбор валидацию не проходит — нужен настоящий клик по
// элементу списка (подтверждено живьём: срабатывает с 1-й попытки; значение поля
// после выбора = выбранной категории).
export async function selectOrdCategory(page, config, category) {
const kktu = page.locator('input[name="selectedKktu"]');
await kktu.click({ force: true });
await humanPause(config);
await kktu.fill('');
await kktu.pressSequentially(task.ordCategory, { delay: 60 });
await kktu.pressSequentially(category, { delay: 60 });
await page.getByRole('option').first().waitFor({ timeout: 10000 });
let option = page.getByRole('option', { name: task.ordCategory, exact: true }).first();
let option = page.getByRole('option', { name: category, exact: true }).first();
if (!(await option.isVisible().catch(() => false))) option = page.getByRole('option').first();
await option.click({ force: true });
await humanPause(config);
@@ -394,18 +475,30 @@ export async function fillAd(page, config, task) {
if (!chosen || /не выбрана/i.test(chosen)) {
throw new Error(`Категория ОРД не проставилась (значение поля: "${chosen}")`);
}
}
// Документ модератору при ПЕРЕСДАЧЕ (задача 3.6): грузим в поле «Комментарий для
// модератора» блока «Дополнительная информация». Это ТРЕТИЙ file-input шага
// «Сообщение» — единственный, принимающий pdf (медиа-инпуты выше — image/video).
// <FLOW-CONFIRM: селектор по accept*="pdf" собран из карты FLOW-FINDINGS.md (Фаза 1
// + «Сессия 6»); живым прогоном пересдачи с этим кодом ещё не подтверждён —
// проверить на следующем цикле с разрешения владельца. Без файла — шаг не трогаем.>
if (task.moderatorFile) {
const docInput = page.locator('input[type="file"][accept*="pdf"]').first();
await docInput.setInputFiles(task.moderatorFile);
await humanPause(config);
}
// Правка полей объявления при ПЕРЕСДАЧЕ (robot-режим resubmit): в редакторе
// отклонённой кампании (шаг «Сообщение») перезаписывает ТОЛЬКО заданные поля —
// текст/ссылку/категорию ОРД — как ответ на причину отказа. null-поля не трогаем
// (у кампании они уже заполнены). Документ модератору грузится отдельно
// (attachModeratorDoc). Переиспользует те же проверенные хелперы, что и fillAd.
export async function editResubmitFields(page, config, task) {
if (task.adText) await fillAdText(page, config, task.adText);
if (task.buttonUrl) await fillAdLink(page, config, task.buttonUrl);
if (task.ordCategory) await selectOrdCategory(page, config, task.ordCategory);
}
// Грузит документ в поле «Комментарий для модератора» блока «Дополнительная
// информация» шага «Сообщение». Это ТРЕТИЙ file-input шага — единственный,
// принимающий pdf (медиа-инпуты объявления выше — image/video). Общий для первичного
// создания (fillAd) и пересдачи (runResubmit).
//
// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A2, 28.07.2026): документ загружен в поле «Комментарий для
// модератора» кампании 2231134 (accept `.png,.jpeg,.jpg,.pdf`).
export async function attachModeratorDoc(page, config, moderatorFile) {
const docInput = page.locator('input[type="file"][accept*="pdf"]').first();
await docInput.setInputFiles(moderatorFile);
await humanPause(config);
}
// Переход «Объявление» → «Стоимость» (/budget).
@@ -496,3 +589,58 @@ export async function finalize(page, config, mode, screenshotPath) {
}
return { launched: false, campaignId, stoppedAt: 'payment' };
}
// Отправка кампании на модерацию БЕЗ ОПЛАТЫ (0 ₽) на шаге /payment — финал robot-
// режима пересдачи (R4). Целит ТОЛЬКО кнопку «Отправить на модерацию без оплаты»
// (isNoPaymentSubmitText); денежные («Списать с баланса…», «Оплатить») НЕ жмёт
// никогда — двойная защита через isForbiddenButtonText. Убеждаемся, что ДО клика мы
// на /payment; после клика — что ушли с /payment (кампания встала на модерацию).
// Кнопки без-оплаты нет / она денежная / не на /payment → падаем громко.
//
// ✅ ПОДТВЕРЖДЕНО ЖИВЬЁМ (A2, 28.07.2026): клик «без оплаты» кампании 2231134 —
// баланс не тронут, кампания вернулась в «На модерации».
export async function submitWithoutPayment(page, config) {
try {
await page.waitForURL(/\/payment$/, { timeout: 30000 });
} catch {
throw new Error(`submitWithoutPayment: ожидали шаг /payment, а URL = ${page.url()}`);
}
await humanPause(config);
const res = await page.evaluate((noPaySrc) => {
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
const noPay = new RegExp(noPaySrc, 'i');
const btn = [...document.querySelectorAll('button,[role=button]')]
.find((b) => noPay.test(norm(b.innerText)));
if (!btn) return { status: 'кнопка-без-оплаты-не-найдена' };
if (btn.disabled || btn.getAttribute('aria-disabled') === 'true')
return { status: 'disabled' };
return { status: 'ready', text: norm(btn.innerText) };
}, NO_PAYMENT_SUBMIT_RE.source);
if (res.status !== 'ready') {
throw new Error(`submitWithoutPayment: кнопка «без оплаты» ${res.status}`);
}
// Двойная защита: даже если regex без-оплаты совпал, текст кнопки не должен быть
// денежным (защита от смены вёрстки МТС, слияния «списать» и «без оплаты»).
if (isForbiddenButtonText(res.text)) {
throw new Error(`submitWithoutPayment: кнопка «${res.text}» опознана как денежная — не жму`);
}
await page.evaluate((noPaySrc) => {
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
const noPay = new RegExp(noPaySrc, 'i');
const btn = [...document.querySelectorAll('button,[role=button]')]
.find((b) => noPay.test(norm(b.innerText)));
if (btn) btn.click();
}, NO_PAYMENT_SUBMIT_RE.source);
// После отправки кабинет уводит со /payment (в список / на страницу кампании).
try {
await page.waitForURL((url) => !/\/payment$/.test(url.toString()), { timeout: 30000 });
} catch {
throw new Error(`submitWithoutPayment: после клика «без оплаты» всё ещё на /payment (${page.url()})`);
}
await humanPause(config);
return { submitted: true };
}
+42
View File
@@ -5,6 +5,7 @@ import { normalizePhones } from './phones.js';
import {
openTelegramWizard, uploadAudience, submitAudience, fillAd, submitAd, submitBudget, finalize,
parseCampaignId, readModerationStatus, MIN_NON_MTS,
openResubmitEditor, attachModeratorDoc, editResubmitFields, submitWithoutPayment,
} from './cabinet.js';
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
import { debugShotsEnabled, alarmScreenshot } from './shots.js';
@@ -31,6 +32,47 @@ export async function runReadStatus(config, campaignId) {
}
}
// Режим resubmit (R5, robot-режим пересдачи): чинит ОТКЛОНЁННУЮ кампанию по id —
// вход через «Исправить» → документ модератору → отправка на модерацию. Повторяет
// доказанный живьём A2 путь. ПДн тут НЕ грузятся (аудитория уже в кампании) — чистить
// нечего. submitMode:'draft' — предохранитель (доходим до /confirmation, не шлём);
// 'live' — жмём «Отправить на модерацию без оплаты» (0 ₽). Возвращает robot-JSON.
export async function runResubmit(config, task, { timestamp }) {
const id = String(task.campaignId);
const screenshot = `screenshots/resubmit-${id}-${timestamp}.png`;
let page, context, step = 'start';
try {
mkdirSync('screenshots', { recursive: true });
({ context, page } = await openBrowser(config, {}));
step = 'session';
if (!(await isLoggedIn(page, config))) throw new Error('Вход слетел — нужен повторный логин');
step = 'resubmit-open'; await openResubmitEditor(page, config, id);
// Вносим исправления в редакторе «Сообщение»: правки текста/ссылки/ОРД (если
// заданы) и/или документ модератору. Хоть одно есть — гарантирует parseResubmitTask.
step = 'edit-fields'; await editResubmitFields(page, config, task);
step = 'attach-doc'; if (task.moderatorFile) await attachModeratorDoc(page, config, task.moderatorFile);
step = 'submit-ad'; await submitAd(page, config); // «Сообщение» → «Стоимость» (/budget)
step = 'submit-budget'; await submitBudget(page, config); // «Стоимость» → «Подтверждение» (/confirmation)
step = 'finalize'; await finalize(page, config, task.submitMode, screenshot);
if (task.submitMode !== 'live') {
// draft-предохранитель: до финала дошли, на модерацию НЕ отправляли.
return { ok: true, campaignId: id, resubmitted: false, stoppedAt: 'confirmation' };
}
// live: на /payment жмём «Отправить на модерацию без оплаты» (0 ₽).
step = 'submit-nopay'; await submitWithoutPayment(page, config);
return { ok: true, campaignId: id, resubmitted: true };
} catch (e) {
return { ok: false, step, reason: e.message, campaignId: id };
} finally {
// Гарантированное закрытие браузера — единственный залогиненный профиль не должен
// висеть. ПДн тут нет (номера не грузятся), временных файлов нет — чистить нечего.
try { await context?.close(); } catch {}
}
}
export async function runTask(config, task, mailer, { timestamp }) {
const screenshot = `screenshots/campaign-${timestamp}.png`;
let page, context, step = 'start';
+33
View File
@@ -1,5 +1,38 @@
const MODES = ['draft', 'live'];
// Пересдача отклонённой кампании (mode:'resubmit', задача 3.6 → robot-mode).
// Вход в редактор через «Исправить» → внести исправления → отправить на модерацию
// без оплаты (0 ₽). Что исправляем — на выбор (в ответ на причину отказа):
// • moderatorFile — документ модератору (лицензия/справка) в поле «Комментарий»;
// • adText / buttonUrl / ordCategory — правка текста/ссылки/категории ОРД.
// Нужна ХОТЬ ОДНА правка: пересдача без изменений = тот же контент → снова отказ.
// НЕ требует phonesFile/budgetRub: аудитория и стоимость у кампании уже есть.
// Обязательное: campaignId (какую чинить), submitMode ∈ {draft, live} (без дефолта,
// чтобы боевую отправку «без оплаты» нельзя было запустить случайно). Незаданные
// поля правок → null (редактор оставляет их как есть).
export function parseResubmitTask(raw) {
const req = (k) => {
if (raw[k] === undefined || raw[k] === '' || raw[k] === null)
throw new Error(`Пересдача: не задано поле ${k}`);
return raw[k];
};
const campaignId = String(req('campaignId'));
const submitMode = req('submitMode');
if (!MODES.includes(submitMode))
throw new Error(`Пересдача: submitMode должен быть draft|live, получено ${submitMode}`);
const opt = (k) => (raw[k] === undefined || raw[k] === '' || raw[k] === null ? null : raw[k]);
const moderatorFile = opt('moderatorFile');
const adText = opt('adText');
const buttonUrl = opt('buttonUrl');
const ordCategory = opt('ordCategory');
if (!moderatorFile && !adText && !buttonUrl && !ordCategory) {
throw new Error('Пересдача: нужна хоть одна правка — документ модератору (moderatorFile) или новый текст/ссылка/категория ОРД');
}
return { mode: 'resubmit', campaignId, submitMode, moderatorFile, adText, buttonUrl, ordCategory };
}
export function parseTask(raw) {
const req = (k) => {
if (raw[k] === undefined || raw[k] === '' || raw[k] === null)
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isForbiddenButtonText, assertCostWithinCap, hrefMatchesCampaignId } from '../src/cabinet.js';
import { isForbiddenButtonText, assertCostWithinCap, hrefMatchesCampaignId, isNoPaymentSubmitText } from '../src/cabinet.js';
// A-фикс: локатор строки кампании в СПИСКЕ. Живой прогон A1 показал: список
// использует href `/cabinet/campaigns/telegram/{id}`, а детальная/визард-страница —
@@ -71,3 +71,33 @@ test('нечитаемая стоимость (null) — не бросает (с
test('некорректный потолок при читаемой стоимости — бросает', () => {
assert.throws(() => assertCostWithinCap('100.00', 0), /потолок|некорректн/i);
});
// R2 (robot-режим пересдачи) — кнопка бесплатной отправки на модерацию (0 ₽).
// submitWithoutPayment целит ТОЛЬКО её; денежные («Списать…», «Оплатить») — никогда.
test('«Отправить на модерацию без оплаты» — кнопка бесплатной отправки', () => {
assert.equal(isNoPaymentSubmitText('Отправить на модерацию без оплаты'), true);
});
test('регистр/пробелы не мешают распознать без-оплаты', () => {
assert.equal(isNoPaymentSubmitText(' ОТПРАВИТЬ НА МОДЕРАЦИЮ БЕЗ ОПЛАТЫ '), true);
});
test('«Списать с баланса и отправить на модерацию» — НЕ без-оплаты (это трата)', () => {
assert.equal(isNoPaymentSubmitText('Списать с баланса и отправить на модерацию'), false);
});
test('«Оплатить» — НЕ без-оплаты', () => {
assert.equal(isNoPaymentSubmitText('Оплатить'), false);
});
test('пустое/не строка — не без-оплаты', () => {
assert.equal(isNoPaymentSubmitText(''), false);
assert.equal(isNoPaymentSubmitText(null), false);
});
// Сверка двух предикатов: кнопка без-оплаты НЕ должна попадать в чёрный список
// денежных, а денежная «Списать…» — должна (двойная защита submitWithoutPayment).
test('без-оплаты не запрещена, «Списать…» запрещена', () => {
assert.equal(isForbiddenButtonText('Отправить на модерацию без оплаты'), false);
assert.equal(isForbiddenButtonText('Списать с баланса и отправить на модерацию'), true);
});
@@ -0,0 +1,82 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseResubmitTask } from '../src/task.js';
// Пересдача (mode:'resubmit', задача 3.6 → robot-mode): чинит ОТКЛОНЁННУЮ кампанию
// по её id и повторно шлёт на модерацию без оплаты (0 ₽). Что чиним — на выбор:
// документ модератору (moderatorFile) и/или правка текста/ссылки/категории ОРД.
const base = {
mode: 'resubmit',
campaignId: '2231134',
submitMode: 'draft',
moderatorFile: 'C:/tmp/licenziya.pdf',
};
test('parseResubmitTask принимает задание с документом модератору', () => {
const t = parseResubmitTask(base);
assert.equal(t.campaignId, '2231134');
assert.equal(t.moderatorFile, 'C:/tmp/licenziya.pdf');
assert.equal(t.submitMode, 'draft');
});
test('parseResubmitTask требует campaignId (какую кампанию чинить)', () => {
const { campaignId, ...rest } = base;
assert.throws(() => parseResubmitTask(rest), /campaignId/);
});
test('parseResubmitTask требует submitMode draft|live (без дефолта — чтобы live не случился сам)', () => {
const { submitMode, ...rest } = base;
assert.throws(() => parseResubmitTask(rest), /submitMode/);
});
test('parseResubmitTask отвергает чужой submitMode', () => {
assert.throws(() => parseResubmitTask({ ...base, submitMode: 'xxx' }), /submitMode/);
});
test('parseResubmitTask принимает submitMode live', () => {
const t = parseResubmitTask({ ...base, submitMode: 'live' });
assert.equal(t.submitMode, 'live');
});
test('parseResubmitTask пробрасывает правки текста/ссылки/ОРД', () => {
const t = parseResubmitTask({
mode: 'resubmit',
campaignId: '2231134',
submitMode: 'draft',
adText: 'Исправленный текст',
buttonUrl: 'https://t.me/liderra',
ordCategory: 'Другая категория',
});
assert.equal(t.adText, 'Исправленный текст');
assert.equal(t.buttonUrl, 'https://t.me/liderra');
assert.equal(t.ordCategory, 'Другая категория');
});
test('parseResubmitTask принимает правку ТОЛЬКО текста, без документа', () => {
const t = parseResubmitTask({
mode: 'resubmit', campaignId: '2231134', submitMode: 'draft', adText: 'Новый текст',
});
assert.equal(t.adText, 'Новый текст');
assert.equal(t.moderatorFile, null);
});
test('parseResubmitTask требует ХОТЬ ОДНО исправление (документ или правку поля)', () => {
// Пересдача без единой правки = тот же контент → снова отказ. Бессмысленно.
assert.throws(
() => parseResubmitTask({ mode: 'resubmit', campaignId: '2231134', submitMode: 'draft' }),
/исправлен|документ|текст/i,
);
});
test('parseResubmitTask: незаданные поля правок → null', () => {
const t = parseResubmitTask(base); // только moderatorFile
assert.equal(t.adText, null);
assert.equal(t.buttonUrl, null);
assert.equal(t.ordCategory, null);
});
test('parseResubmitTask НЕ требует phonesFile/budgetRub (аудитория и стоимость уже у кампании)', () => {
const t = parseResubmitTask(base);
assert.equal(t.phonesFile, undefined);
assert.equal(t.budgetRub, undefined);
});
@@ -0,0 +1,88 @@
# Робот-режим пересдачи (`mode:'resubmit'`) — приёмочный лист + состояние
Ветка `feat/client-telegram-ads`. Хвост №1 из STATE `2026-07-28-telegram-review-fixes-STATE.md`.
Владелец выбрал «Робот: режим пересдачи» (28.07.2026). Не пушить. Коммит только по «коммить».
**Что это.** Робот берёт ОТКЛОНЁННУЮ кампанию, входит в её редактор через «Исправить»,
прикладывает документ модератору и повторно отправляет на модерацию **без оплаты** (0 ₽).
Полный цикл уже проверен живьём (A2, кампания 2231134); здесь — оформление в боевой режим
робота `mode:'resubmit'` (было: одноразовый скрипт).
**Опора на живьём-проверенные селекторы (A2):**
- вход: «Исправить» в строке кампании → редактор открывается на `/telegram-a2p/{id}/message`;
- документ: `input[type="file"][accept*="pdf"]` (поле «Комментарий для модератора») — уже в `fillAd`;
- отправка без денег: кнопка `/отправить на модерацию без оплаты/i` на `/payment` (0 ₽, баланс не тронут).
**Метод:** TDD на ЧИСТЫХ функциях (как весь робот-набор). DOM-функции (вход «Исправить»,
отправка «без оплаты») юнит-тестами НЕ покрываются — их правда только живьём (проверено A2),
но денежная безопасность вынесена в чистый предикат и покрыта тестом.
## Приёмочный лист
- [x] **R1. `parseResubmitTask(raw)`** (task.js) — принимает `{mode:'resubmit', campaignId,
submitMode:'draft'|'live', + правки}`.
- требует `campaignId` (какую чинить) и `submitMode` ∈ {draft, live} (без дефолта — чтобы live
не случился случайно);
- правки на выбор: `moderatorFile` (документ модератору) и/или `adText`/`buttonUrl`/`ordCategory`
(правка текста/ссылки/ОРД); нужна ХОТЬ ОДНА — иначе тот же контент снова отклонят;
- НЕ требует phonesFile/budgetRub (аудитория и стоимость у кампании уже есть).
- Приёмка: валидное задание проходит; без campaignId/submitMode/без единой правки — понятная
ошибка; базовые `parseTask` (draft/live/read-status) не затронуты. Тест `resubmit-task.test.js` (10/10).
- ➕ РАСШИРЕНО (по просьбе владельца 28.07): вернули правки текста/ссылки/ОРД (были заужены).
DOM: `fillAd` разбит на хелперы `fillAdText`/`fillAdLink`/`selectOrdCategory`/`fillAdMedia`,
пересдача переиспользует их через `editResubmitFields` (правит только заданные поля).
- [x] **R2. `NO_PAYMENT_SUBMIT_RE` + `isNoPaymentSubmitText(text)`** (cabinet.js) — чистый предикат
«это кнопка бесплатной отправки на модерацию» (true для «Отправить на модерацию без оплаты»),
и он НЕ пересекается с денежными («Списать…», «Оплатить»). Приёмка: без-оплаты → true;
«Списать с баланса…» → false; «Оплатить» → false; пустое/не строка → false. Сверка с
`isForbiddenButtonText`: без-оплаты не запрещена, денежные — запрещены. Тест `cabinet-guards.test.js` (+6).
- [x] **R3. `openResubmitEditor(page, config, id)`** (cabinet.js) — список → нативный клик «Исправить»
в СТРОКЕ кампании `id` (scope по `campaignHrefRe`, как чтение статуса) → ждёт URL
`/telegram-a2p/{id}/message` + поле текста. Ряд/кнопка не найдены или не тот URL → падаем громко.
(DOM, ✅ ПОДТВЕРЖДЕНО A2.)
- [x] **R4. `submitWithoutPayment(page, config)`** (cabinet.js) — на `/payment` нативный клик
ТОЛЬКО кнопки `NO_PAYMENT_SUBMIT_RE`; если совпала денежная — падаем (двойная защита через
`isForbiddenButtonText`). Убеждаемся, что ДО клика мы на `/payment`; после — ушли с `/payment`
(кампания на модерации). Кнопки без-оплаты нет/disabled → падаем громко. (DOM, ✅ ПОДТВЕРЖДЕНО A2.)
- [x] **R5. `runResubmit(config, task, {timestamp})`** (runner.js) — браузер → логин →
`openResubmitEditor` → `attachModeratorDoc` (общий с fillAd, DRY) → `submitAd` → `submitBudget` →
`finalize(submitMode)`; при `submitMode:'live'` затем `submitWithoutPayment`. Гарантированное
закрытие браузера в finally. ПДн НЕ грузятся (номера уже в кампании) — чистить нечего.
- `draft`: доходим до `/confirmation`, НЕ отправляем → `{ok:true, resubmitted:false, stoppedAt:'confirmation'}`.
- `live`: доходим до `/payment`, жмём «без оплаты» → `{ok:true, resubmitted:true, campaignId}`.
- ошибка → `{ok:false, step, reason, campaignId}`.
- [x] **R6. bin/run.js** — ветка `rawTask.mode === 'resubmit'` ДО `parseTask` (как read-status):
`parseResubmitTask` → `runResubmit` → печать JSON → код выхода `ok?0:1`.
## Приёмка (28.07.2026)
Робот `npm test` **110/110** (было 94, +16: resubmit-task 10 + cabinet-guards +6). Модули грузятся
без ошибок; stdout не засоряется (JSON только в bin/run.js).
Пример боевого задания: `{ "mode":"resubmit", "campaignId":"2231134", "submitMode":"live",
"adText":"…", "moderatorFile":"screenshots/licenziya.pdf" }`.
## ✅ ЖИВАЯ ПРОВЕРКА (28.07.2026) — оба сценария на реальных отклонённых кампаниях
Вход в кабинет жив; отклонённые reject-probe кампании 2231132 и 2231134 (несуществующий канал).
- **DRAFT** (2231132, правка текста + документ png): робот вошёл через «Исправить», отредактировал
текст, загрузил документ, дошёл `/message→/budget→/confirmation` и НЕ отправил →
`{ok:true, resubmitted:false, stoppedAt:'confirmation'}`. ✅
- **LIVE** (2231134, правка текст+ссылка+ОРД, БЕЗ документа): полный цикл до `/payment` → клик
«Отправить на модерацию без оплаты» (0 ₽) → `{ok:true, resubmitted:true}`; повторный `read-status`
подтвердил `moderationStatus:'moderating'`. ✅ (кнопка «без оплаты» = не денежная, баланс не тронут).
Проверены живьём все три пути правок (текст/ссылка/ОРД) + документ + отправка без оплаты.
## Оставлено на потом (вне объёма)
- Связка Laravel→robot `mode:'resubmit'`: на стороне Laravel `resubmit` контроллера (3.6) есть;
какой RobotResult ждёт джоб пересдачи и как он зовёт робота с mode:resubmit — отдельный шаг.
- Реальный живой прогон нового `runResubmit` — только с разрешения владельца в кабинете
(селекторы доказаны A2 одноразовым скриптом; здесь — тот же код, оформленный в боевой режим).
- Опц. редактирование текста/ссылки/ОРД при пересдаче (ответ на контентный отказ) — YAGNI, позже.
## Семантика 'live' (важно, не перепутать)
В БАЗОВОЙ кампании (`runTask`) `finalize('live')` СТОИТ на `/payment` (боевая отправка/оплата — go-live D,
Сессия 6). В ПЕРЕСДАЧЕ (`runResubmit`) `live` идёт на шаг дальше и жмёт «без оплаты» (0 ₽) — это и есть
суть пересдачи (доказано A2, денег не тратит). Разные раннеры — расхождение осознанное.