Files
portal/app/resources/js/api/telegram.ts
T
Дмитрий 7922bffb6d
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(телеграм-реклама): заголовок и картинка в авто-режиме + честная проверка медиа по требованиям МТС
Пачка 1 замечаний владельца о едином виде рекламных экранов. Песочница выключена
02.08.2026 — каждая дыра ниже стоила живых денег.

1. Заголовок объявления в АВТО-правиле. Кабинет МТС требует его для рекламы сайта;
   утром 02.08 поле довезли до разовой формы, а в авто его не было вовсе — накопитель
   создавал кампанию без ad_headline, и она сгорела бы в кабинете уже после списания.
   Колонка client_tg_auto_rule.ad_headline, приём в контроллере (обязателен только при
   включённом авто и не-телеграмной ссылке), перенос в кампанию, поле на экране.

2. Картинка/видео в авто-правиле. Колонка media_path, отдельная загрузка
   POST /api/telegram/auto-rule/media, перенос в кампанию, поле на экране.

3. Проверка медиа переписана по настоящим требованиям кабинета, снятым глазами 02.08.
   Было mimes:png,jpg,jpeg,gif,mp4|max:51200 — врало по пяти пунктам: принимало GIF,
   пропускало вдвое больший вес, не смотрело пиксели и длительность, зря отказывало
   в mov/webm. Стало правило App\Rules\ClientTg\MtsMedia: картинка JPEG/PNG до 25 МБ
   и 640x360...5120x2880, видео до 20 МБ, 3-55 секунд, от 640x360. Формат картинки
   определяется по содержимому, длительность и кадр видео читает App\Support\Mp4Probe
   из контейнера — без внешних программ. Отказ человеческий: «Картинка слишком
   маленькая: 300x200. Нужна не меньше 640x360».

4. Цена показа с медиа — 600 руб. с картинкой, 680 с видео — теперь видна ДО загрузки
   файла, на обоих экранах.

Сверх плана, найдено по дороге:

5. Предохранитель накопителя: правило, сохранённое до 02.08 со ссылкой на сайт и пустым
   заголовком, всё равно ушло бы в кабинет. Теперь такая пачка держится черновиком,
   в журнал пишется причина no_headline.

6. Отказ сервера доходит до клиента его словами. Оба экрана глушили ответ общей фразой
   «Не удалось рассчитать кампанию», и человек не понимал, что не так с файлом.

Границы честности: длительность и размер кадра читаются только у mp4/mov/m4v; у
webm/mkv/mpeg/wmv проверяются формат и вес — так и записано в Mp4Probe.

Проверено: 281 тест телеграм-модуля, 1753 фронтенд-теста, статанализ 0, Pint чист.
Глазами НЕ принимали — приёмка живьём в пачке 5. На боевой не выкачено.

Журнал схемы: v9.64 — номер взят как максимум по всем веткам плюс один, чтобы не
повторить столкновения 29.07 и 01.08.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:41:26 +03:00

189 lines
9.2 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';
/**
* 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;
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;
}
/** 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.';
/**
* Показ с медиа дороже обычного — цены кабинета МТС на 02.08.2026. Клиент должен
* увидеть это ДО загрузки файла, а не после списания денег.
*/
export const MEDIA_CENA = 'С картинкой показ стоит дороже: 600 ₽ за тысячу, с видео — 680 ₽.';
/**
* 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;
}