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:
@@ -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` тихо проверяет, жив ли вход в кабинет. Если
|
||||
|
||||
@@ -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, '-');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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, денег не тратит). Разные раннеры — расхождение осознанное.
|
||||
Reference in New Issue
Block a user