Files
portal/app/resources/js/api/telegram.ts
T
Дмитрий 337f6b9897 feat телеграм: дата старта показов, признак живости системы и человеческий язык ошибок
Дата старта (решение владельца 06.08.2026). Портал не передаёт кабинету МТС ни одной
даты — их ставит кабинет своими умолчаниями. Живой прогон показал: старт оказался
ЗАВТРАШНИМ, тогда как экран обещал клиенту показы «7 дней», подразумевая сегодня.
Робот читает день начала из ТОЙ ЖЕ строки списка, куда и так ходит за вердиктом —
ни одного лишнего захода в кабинет; портал хранит его в client_tg_campaigns.starts_on
и показывает клиенту «Показы начнутся 7 августа». Проверено на ЖИВОМ кабинете:
три задания подряд вернули startDate 2026-08-07 по кампании МТС 2237821.
Мастер перестал молчать о том, что день начала ставит кабинет, а не мы.

Ф-2, карточка приёмки Т-Ф4. У кампаний в движении видно «Проверяли 5 минут назад».
Считаются только ЗАКОНЧЕННЫЕ проверки, включая неудачные: задание в очереди работой
не является, а неудачная проверка — всё равно признак жизни. Именно в такой тишине
владелец 36 часов не знал, что робот вообще не может войти в кабинет.

Ф-3. Имена полей в ошибках формы по-русски: «Лимит на объявление не может быть меньше
1 ₽» вместо «Поле budget cap rub должно быть не меньше 1». Серая кнопка «Запустить»
называет причину и шаг, куда вернуться, а не гаснет молча.

Карточки Т-Р3 и Т-Р4 закрыты тестами (живьём не воспроизвести). Попутно найдено: обе
защиты, стерегущие ЕДИНСТВЕННОЕ место траты живых денег роботом, были без единого
теста. Сторожа доказаны вырезанием.

Тесты: ClientTg 378 зелёных, экраны 2158, робот 197. Статанализ 0, стиль 0, типы чисто.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:33:35 +03:00

298 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiClient, ensureCsrfCookie } from './client';
import type { CenyZaTysyachu } from '../composables/podskazkiReklamy';
/**
* API-модуль клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.2).
*
* Эндпоинты под [auth:sanctum, tenant], префикс /api/telegram. GET'ы не требуют
* CSRF-cookie, мутации (POST) — требуют.
*
* 🔴 Отличие от СМС-близнеца: создание (create) и запуск (launch) РАЗДЕЛЕНЫ.
* create возвращает ЧЕРНОВИК со сметой и числом кандидатов; launch переводит его
* в очередь и запускает робота кабинета МТС (в песочнице — только черновик).
*/
/** Способ выбора аудитории: свежие сделки за период / своя база / свой список. */
export type TelegramAudienceKind = 'deals' | 'base' | 'list';
/** Кампания клиентской Telegram-рекламы (строка списка, POST-ответ и часть GET .../{id}). */
export interface TelegramCampaign {
id: number;
status: string;
status_reason?: string | null;
ad_text: string;
ad_link: string;
media_path: string | null;
/** Документ модератору, приложённый при пересдаче отклонённой кампании (задача 3.6). */
moderator_file_path?: string | null;
ord_category: string;
budget_cap_rub: string;
audience_kind: TelegramAudienceKind;
audience_params: Record<string, unknown> | null;
planned_count: number;
matched_count: number | null;
estimated_cost_rub: string;
/**
* День начала показов «ГГГГ-ММ-ДД». Ставит его кабинет МТС (портал дат не задаёт),
* читает робот вместе с вердиктом модерации. null — робот ещё не сходил либо в
* строке кабинета даты не было.
*/
starts_on?: string | null;
/**
* Когда система последний раз ЗАКОНЧИЛА проверку этой кампании роботом. Признак
* жизни для клиента: без него кампания молча висит «На модерации», и работу от
* поломки не отличить (дефект Ф-2). null — проверок ещё не было.
*/
last_robot_check_at?: string | null;
actual_cost_rub: string | null;
/** null = авто-кампания (накопитель), иначе id создавшего менеджера. */
created_by?: number | null;
created_at?: string;
}
/** Ответ GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
export interface TelegramIndex {
campaigns: TelegramCampaign[];
sandbox: boolean;
balance_rub: string;
frozen_rub: string;
}
/** Тело POST /api/telegram/campaigns — создание черновика кампании. */
export interface TelegramCreatePayload {
ad_text: string;
ad_link: string;
audience_kind: TelegramAudienceKind;
budget_cap_rub: string;
audience_days?: number;
ord_category?: string;
phones?: string[];
/** Заголовок объявления — кабинет МТС требует его для рекламы САЙТА (до 40 знаков). */
ad_headline?: string;
}
/** GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
export async function fetchTelegram(): Promise<TelegramIndex> {
const { data } = await apiClient.get<TelegramIndex>('/api/telegram/campaigns');
return data;
}
/** POST /api/telegram/campaigns — создать ЧЕРНОВИК кампании (деньги/робот не трогаются). */
export async function createTelegram(payload: TelegramCreatePayload): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramCampaign>('/api/telegram/campaigns', payload);
return data;
}
/** Ответ оценки охвата: сколько людей и во сколько обойдётся, плюс порог площадки. */
export interface TelegramOcenka {
planned_count: number;
estimated_cost_rub: string;
/** Минимум кандидатов у МТС Маркетолог — правило площадки, не наше. */
min_count: number;
enough: boolean;
/**
* Цена ЗА ТЫСЯЧУ показов по каждому виду медиа, ₽ — то, что заплатит клиент.
*
* 🔴 Экран не имеет права хранить эти числа у себя. Подсказка «?» у поля медиа
* держала их в тексте («600 ₽ с картинкой, 680 ₽ с видео») и протухла в ту минуту,
* когда миграция поменяла тариф: клиенту обещали вдвое дешевле, чем списывали.
*/
ceny_za_tysyachu: CenyZaTysyachu;
}
/** Что спрашиваем у оценки — только про аудиторию, объявление на охват не влияет. */
export interface TelegramOcenkaPayload {
audience_kind: TelegramAudienceKind;
audience_days?: number;
phones?: string[];
/**
* Вид медиа объявления: у МТС показ с картинкой и видео стоит дороже, и смета
* обязана считаться по тому, что клиент выбрал. Не передан — считаем без медиа.
*/
media_kind?: 'none' | 'image' | 'video';
}
/**
* POST /api/telegram/campaigns/estimate — сколько людей увидит рекламу и во сколько
* это обойдётся, БЕЗ создания кампании.
*
* 🔴 Не путать с `createTelegram`: та тоже возвращает охват, но СОЗДАЁТ черновик в базе.
* Живой счётчик дёргается на каждое изменение поля, и через неё он засорил бы клиенту
* список кампаний десятками призраков.
*/
export async function estimateTelegram(payload: TelegramOcenkaPayload): Promise<TelegramOcenka> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramOcenka>('/api/telegram/campaigns/estimate', payload);
return data;
}
/** GET /api/telegram/campaigns/{id} — детали кампании. */
export async function fetchTelegramCampaign(id: number): Promise<{ campaign: TelegramCampaign }> {
const { data } = await apiClient.get<{ campaign: TelegramCampaign }>(`/api/telegram/campaigns/${id}`);
return data;
}
/** POST /api/telegram/campaigns/{id}/launch — запустить черновик (draft → queued + робот). */
export async function launchTelegram(id: number): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/launch`);
return data;
}
/** Тело POST /api/telegram/campaigns/{id}/resubmit — правки + опц. документ модератору. */
export interface TelegramResubmitPayload {
ad_text: string;
ad_link: string;
ord_category?: string;
/** Лицензия/договор для модератора МТС (png/jpg/pdf ≤10 МБ). */
moderator_file?: File | null;
}
/**
* POST /api/telegram/campaigns/{id}/resubmit — пересдать ОТКЛОНЁННУЮ кампанию
* (rejected → queued). Отправляем multipart, т.к. можно приложить документ модератору.
*/
export async function resubmitTelegram(id: number, payload: TelegramResubmitPayload): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const form = new FormData();
form.append('ad_text', payload.ad_text);
form.append('ad_link', payload.ad_link);
if (payload.ord_category) {
form.append('ord_category', payload.ord_category);
}
if (payload.moderator_file) {
form.append('moderator_file', payload.moderator_file);
}
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/resubmit`, form);
return data;
}
/**
* Что кабинет МТС принимает в поле медиа (снято глазами 02.08.2026). Список идёт
* в атрибут accept у полей загрузки — чтобы клиенту в проводнике не показывали
* заведомо негодные файлы. Настоящая проверка — на сервере (правило MtsMedia).
*/
export const MEDIA_ACCEPT =
'image/jpeg,image/png,video/mp4,video/webm,video/quicktime,video/x-matroska,video/mpeg,video/x-m4v,video/x-ms-wmv';
/** Требования МТС к медиа человеческим языком — подсказка под полем загрузки. */
export const MEDIA_TREBOVANIYA =
'Картинка: JPEG или PNG, до 25 МБ, от 640×360 до 5120×2880. Видео: до 20 МБ, 3–55 секунд, от 640×360.';
/**
* Показ с медиа дороже обычного — клиент должен знать это ДО загрузки файла, а не
* после списания денег.
*
* 🔴 Чисел здесь намеренно НЕТ. Раньше стояло «600 ₽ за тысячу, с видео — 680 ₽» —
* это была закупочная цена МТС без НДС, а не то, что платит клиент; вдобавок она
* протухала бы при любой правке тарифа в админке. Точную сумму считает сервер и
* показывает смета — она знает и про медиа, и про наценку.
*/
export const MEDIA_CENA =
'Картинка и видео делают показ дороже. Смета пересчитается сама, как только выберете файл.';
/**
* POST /api/telegram/campaigns/{id}/media — приложить картинку/видео к ЧЕРНОВИКУ
* объявления. Требования — см. MEDIA_TREBOVANIYA. Робот отдаёт файл кабинету МТС
* при запуске.
*/
export async function uploadTelegramMedia(id: number, file: File): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const form = new FormData();
form.append('media', file);
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/media`, form);
return data;
}
/**
* Правило авто-рекламы Telegram (план §Этап 5, задача 5.4). Одно на тенанта: клиент
* из кабинета включает авто, задаёт объявление, порог пачки, бюджет и дневной лимит.
*/
export interface TelegramAutoRule {
enabled: boolean;
ad_text: string;
ad_link: string;
/** Заголовок объявления — МТС требует его, когда реклама ведёт на САЙТ (до 40 знаков). */
ad_headline: string;
/** Картинка/видео объявления; грузится отдельно (uploadAutoRuleMedia), в PUT не ходит. */
media_path?: string | null;
ord_category: string;
budget_cap_rub: string;
daily_limit_rub: string;
/** Сколько кандидатов накопить перед отправкой пачки; null → порог по умолчанию (367). */
batch_threshold: number | null;
}
/** GET /api/telegram/auto-rule — правило авто-рекламы тенанта (или дефолты). */
export async function fetchAutoRule(): Promise<TelegramAutoRule> {
const { data } = await apiClient.get<TelegramAutoRule>('/api/telegram/auto-rule');
return data;
}
/** PUT /api/telegram/auto-rule — сохранить правило авто-рекламы. */
export async function saveAutoRule(payload: TelegramAutoRule): Promise<TelegramAutoRule> {
await ensureCsrfCookie();
const { data } = await apiClient.put<TelegramAutoRule>('/api/telegram/auto-rule', payload);
return data;
}
/**
* POST /api/telegram/auto-rule/media — приложить картинку/видео к авто-правилу.
* Оно повторяется в каждой авто-пачке. Правило должно быть уже сохранено — иначе 422.
*/
export async function uploadAutoRuleMedia(file: File): Promise<TelegramAutoRule> {
await ensureCsrfCookie();
const form = new FormData();
form.append('media', file);
const { data } = await apiClient.post<TelegramAutoRule>('/api/telegram/auto-rule/media', form);
return data;
}
// --- «Моя база номеров» (client_tg_contacts) --------------------------------
//
// 🔴 Наружу ходят только счётчики. Номера — персональные данные, экрану они не нужны:
// он показывает «в базе N номеров», а сами номера остаются на сервере.
/** Сколько номеров в базе тенанта. */
export interface TgBazaRazmer {
count: number;
}
/** Итог заливки файла: что приняли, что схлопнули, что не разобрали. */
export interface TgBazaItog extends TgBazaRazmer {
accepted: number;
duplicates: number;
rejected: number;
/** Файл оказался длиннее потолка — взяли первые `max` номеров. */
oborvano: boolean;
max: number;
}
/** GET /api/telegram/contacts — размер базы. */
export async function fetchTgBaza(): Promise<TgBazaRazmer> {
const { data } = await apiClient.get<TgBazaRazmer>('/api/telegram/contacts');
return data;
}
/**
* POST /api/telegram/contacts — залить файл с номерами.
*
* 🪤 ЗАМЕНЯЕТ базу целиком, а не добавляет к ней. Экран обязан сказать об этом до
* нажатия, иначе клиент узнает о потере после.
*/
export async function uploadTgBaza(file: File): Promise<TgBazaItog> {
await ensureCsrfCookie();
const form = new FormData();
form.append('file', file);
const { data } = await apiClient.post<TgBazaItog>('/api/telegram/contacts', form);
return data;
}
/** DELETE /api/telegram/contacts — очистить базу целиком. */
export async function clearTgBaza(): Promise<TgBazaRazmer> {
await ensureCsrfCookie();
const { data } = await apiClient.delete<TgBazaRazmer>('/api/telegram/contacts');
return data;
}