From d50a93e55c37b97d6d820e0c549c4f33708bddcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 28 Jul 2026 16:43:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=82=D0=B5=D0=BB=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BC-=D1=80=D0=BE=D0=B1=D0=BE=D1=82):=20=D0=B1=D0=BE=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D0=B4=D0=B0=D1=87=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=BB=D0=BE=D0=BD=D1=91=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8=20(mode:resubmit)=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=B6=D0=B8=D0=B2=D1=8C=D1=91=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хвост №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 --- bots/mts-telegram-ads/README.md | 37 ++++ bots/mts-telegram-ads/bin/run.js | 15 +- bots/mts-telegram-ads/src/cabinet.js | 202 +++++++++++++++--- bots/mts-telegram-ads/src/runner.js | 42 ++++ bots/mts-telegram-ads/src/task.js | 33 +++ .../test/cabinet-guards.test.js | 32 ++- .../test/resubmit-task.test.js | 82 +++++++ .../2026-07-28-robot-resubmit-mode-ACCEPT.md | 88 ++++++++ 8 files changed, 501 insertions(+), 30 deletions(-) create mode 100644 bots/mts-telegram-ads/test/resubmit-task.test.js create mode 100644 docs/superpowers/2026-07-28-robot-resubmit-mode-ACCEPT.md diff --git a/bots/mts-telegram-ads/README.md b/bots/mts-telegram-ads/README.md index 0d23eae0..93c1bd11 100644 --- a/bots/mts-telegram-ads/README.md +++ b/bots/mts-telegram-ads/README.md @@ -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` тихо проверяет, жив ли вход в кабинет. Если diff --git a/bots/mts-telegram-ads/bin/run.js b/bots/mts-telegram-ads/bin/run.js index bf555828..e14cc8c4 100644 --- a/bots/mts-telegram-ads/bin/run.js +++ b/bots/mts-telegram-ads/bin/run.js @@ -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, '-'); diff --git a/bots/mts-telegram-ads/src/cabinet.js b/bots/mts-telegram-ads/src/cabinet.js index 79249486..5d1fd115 100644 --- a/bots/mts-telegram-ads/src/cabinet.js +++ b/bots/mts-telegram-ads/src/cabinet.js @@ -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). - // - 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 }; +} diff --git a/bots/mts-telegram-ads/src/runner.js b/bots/mts-telegram-ads/src/runner.js index 4e295bca..0afb7f4d 100644 --- a/bots/mts-telegram-ads/src/runner.js +++ b/bots/mts-telegram-ads/src/runner.js @@ -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'; diff --git a/bots/mts-telegram-ads/src/task.js b/bots/mts-telegram-ads/src/task.js index e02b9383..ce5c5a8f 100644 --- a/bots/mts-telegram-ads/src/task.js +++ b/bots/mts-telegram-ads/src/task.js @@ -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) diff --git a/bots/mts-telegram-ads/test/cabinet-guards.test.js b/bots/mts-telegram-ads/test/cabinet-guards.test.js index 7e1a7305..b19ca0ac 100644 --- a/bots/mts-telegram-ads/test/cabinet-guards.test.js +++ b/bots/mts-telegram-ads/test/cabinet-guards.test.js @@ -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); +}); diff --git a/bots/mts-telegram-ads/test/resubmit-task.test.js b/bots/mts-telegram-ads/test/resubmit-task.test.js new file mode 100644 index 00000000..31334095 --- /dev/null +++ b/bots/mts-telegram-ads/test/resubmit-task.test.js @@ -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); +}); diff --git a/docs/superpowers/2026-07-28-robot-resubmit-mode-ACCEPT.md b/docs/superpowers/2026-07-28-robot-resubmit-mode-ACCEPT.md new file mode 100644 index 00000000..125e14bf --- /dev/null +++ b/docs/superpowers/2026-07-28-robot-resubmit-mode-ACCEPT.md @@ -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, денег не тратит). Разные раннеры — расхождение осознанное.