javascript

Почему рекламный пост в Telegram нельзя публиковать через copyMessages

  • пятница, 28 августа 2026 г. в 00:00:07
https://habr.com/ru/articles/1075242/

Если бот копирует один и тот же пост в десять каналов, в Google Analytics все клики свалятся в одну кучу. URL у скрытой ссылки живёт не в тексте, а в MessageEntity, а copyMessages копирует его как есть. Ниже — как пересобрать пост под каждую площадку, не сломав жирный шрифт, эмодзи и health‑check.

Telegram умеет одно очень удобное действие: copyMessages. Берёте сообщение из чата A, кладёте в чат B — форматирование, медиа, альбом, скрытые ссылки, всё на месте. Для рассылок и зеркал это идеальный метод.

Для рекламы он ломает самое важное.

Рекламодатель покупает размещения в разных каналах и смотрит в свою аналитику: откуда пришёл клик. Если во всех десяти постах в text_link лежит один и тот же https://shop.example/sale, UTM бесполезен. Каналы неразличимы. Сделка неразличима. Дата размещения неразличима.

copyMessages как ксерокс: десять каналов, один и тот же спрятанный URL. Метки вписать некуда.

Казалось бы: скопировал пост, потом editMessageText и дописал метки. Не выйдет. Текст поста не содержит URL. URL сидит в entity. А copyMessages не даёт подменить entities на лету.

Поэтому публикацию сделки приходится пересобирать из сохранённого креатива: текст + entities + file_id. Превью в личке бота при этом оставлять нетронутым — человек должен видеть тот же пост, который он согласовал, без служебных меток.

Где на самом деле лежит ссылка

В Telegram два вида ссылок.

Обычный URL в тексте: https://example.com. Его видно глазами, его можно найти регуляркой, его можно дописать через ?utm_.... Рекламодатели так почти не делают. В рекламном посте ссылка почти всегда скрытая: слово «Купить» синее, а адрес спрятан.

Это entity типа text_link:


{
  type: "text_link",
  offset: 14,   // UTF-16 code units, не символы JS-итератора
  length: 6,
  url: "https://shop.example/sale"
}

offset и length описывают видимый кусок текста. Само поле url в текст не входит. Если вы перепишете url, длина текста не изменится, жирный и курсив рядом не поедут. Это и есть правильная точка для UTM.

Переписывать нужно только https:// и http://. tg:// и mailto: лучше не трогать: это диплинки в клиента, а не лендинг с аналитикой.

Схема меток, которая нам хватила:

Параметр

Зачем

utm_source=adbento

источник в кабинете рекламодателя

utm_medium=telegram

канал трафика

utm_campaign=<campaignId>

какая кампания

utm_content=@channel|2026-08-02

какая площадка и какой день слота

utm_term=<dealId>

какая сделка

Если у канала нет @username, в utm_content идёт c-100123456. Хеш в исходном URL (#top) сохраняем. Чужие query‑параметры тоже. Свои utm_* перезаписываем — иначе повторная публикация накрутит дубли.

Пример:


https://site.com/x?ref=1#top
→
https://site.com/x?ref=1&utm\\_source=adbento&utm\\_medium=telegram
  &utm_campaign=camp_1
  &utm_content=%40mychannel%7C2026-08-02
  &utm_term=deal_1#top

| в utm_content кодируется в %7C. Это нормально: так и должно быть в query.

Два пути публикации

Креатив хранится дважды.

  1. Канон в ЛС бота: canonicalChatId + canonicalMessageIds. Это «как рекламодатель это видел». Отсюда удобно copyMessages — пиксель в пиксель, включая медиагруппу.

  2. Структурированная копия в БД: creativeText, creativeEntities, массив file_id. Это то, из чего можно пересобрать пост с другими URL.

Правило простое:

  • превью в ЛС, доставка админу «посмотрите, что выйдет» — copyMessages или rebuild без UTM;

  • публикация сделки в канал, репост, health‑check — всегда rebuild с UTM.

В коде это выглядит так:


async function publishCreative(chatId, campaign, placement?) {
  // copyMessages вставляет канон как есть — без per-deal UTM
  if (!placement && campaign.canonicalChatId && campaign.canonicalMessageIds.length) {
    try {
      const copied = await bot.api.copyMessages(
        chatId,
        Number(campaign.canonicalChatId),
        campaign.canonicalMessageIds,
      );
      if (copied.length === campaign.canonicalMessageIds.length) {
        return copied.map((m) => m.message_id);
      }
    } catch { /* упадём в rebuild */ }
  }
const entities = placement
? tagCreativeLinksForDeal(campaign.creativeEntities, placement)
: campaign.creativeEntities;
// sendMessage / sendPhoto / sendMediaGroup из текста, entities и file_id
}

placement есть только у сделки. Нет placement — нет меток. Поэтому превью в боте и живой пост в канале намеренно разные. Это не баг.

Rebuild медиа идёт через сохранённые file_id. Перед отправкой каждый файл проверяем getFile: Telegram иногда отдаёт file_id, который уже нельзя использовать. Пустой пост (нет текста и нет валидного медиа) не отправляем вообще — лучше ретрай, чем «опубликовали дырку» и списали эскроу.

UTF-16, или почему «смайлик посередине» ломает жирный

😀 занимает два code unit. Если bold разрезать суррогат пополам — Bot API ответит ошибкой диапазона.

Bot API считает offset/length в UTF-16 code units. JavaScript string.length — тоже UTF-16. Кажется, повезло.

Не повезло, если вы меряете длину как [...text].length или text.length в Python 3 без оговорок. Эмодзи 😀 — это два code unit (U+D83D U+DE00). Суррогатная пара. Если entity начинается или кончается внутри пары, Telegram ответит ошибкой диапазона.


const text = "A😀B"; // length === 4, не 3
// так можно: покрыли весь эмодзи
{ type: “bold”, offset: 1, length: 2 }
// так нельзя: разрезали суррогат
{ type: “bold”, offset: 1, length: 1 }

Перед сохранением креатива нормализуем entities:

  • целые offset/length, диапазон внутри text.length;

  • не режем суррогатную пару;

  • частичные пересечения запрещены (bold 0–4 и italic 2–6 — ошибка);

  • вложенность стилей разрешена (bold снаружи, italic внутри);

  • внутрь code/pre и цитаты в цитату — нельзя, так устроен Bot API;

  • URL у text_link только https?://, tg://, mailto:.

Последнее — не про вкусовщину. Редактор креатива в Mini App сериализует DOM в HTML. Если пропустить javascript:, его можно сложить в innerHTML и получить XSS. Белый список схем это отсекает на входе.

Ещё одна мелочь редактора: contenteditable на Enter плодит <div> и <p>. Telegram ждёт \n. При сериализации соседние блок‑теги склеиваем переводом строки, иначе в канале поедет вёрстка относительно того, что человек видел в форме.

Ловушка, из‑за которой метки исчезают через час

Health‑check без UTM: пост «живой», синее «Купить» на месте, аналитика уже пустая.

Публикация с UTM — половина задачи. Вторая половина — не стереть их сами.

Размещение живёт сутки (или несколько суток с ежедневным репостом наверх ленты). Воркер раз в час проверяет, что пост на месте. Если админ удалил — публикуем снова, теми же метками. Если бота выкинули из канала — сделка failed_publisher, холд возвращаем рекламодателю.

Откуда взять просмотры? У Bot API нет нормального getMessage. getChat просмотры поста не отдаёт.

Поэтому смотрим в три источника, по убыванию адекватности:

  1. MTProto‑сессия того же бота (api_id / api_hash + токен) — channels.getMessages, там есть views.

  2. Публичный превью t.me/s/username — только для каналов с @username.

  3. Костыль: editMessageText тем же текстом. Успешный edit возвращает объект Message, а у канала в нём бывает views.

Третий путь опасный. Если в editMessageText передать исходные entities без UTM, Telegram послушно перезапишет ссылки. Через час после публикации все метки исчезнут. Рекламодатель увидит в аналитике ровно один «канал» — никакой. А пост визуально не изменится: текст тот же, синее слово то же.

Поэтому health‑check зовёт ту же функцию, что и публикация: tagCreativeLinksForDeal. Идемпотентно. Перезапись своих utm_* безопасна.

Вторая ловушка того же edit: если текст и entities совпадают с тем, что уже в канале, Telegram отвечает message is not modified. Тела Message нет, просмотров нет.

Обход — нулевой ширины пробел U+200B:


const ZWSP = "\u200B";
const touched = text.endsWith(ZWSP) ? text.slice(0, -1) : `${text}${ZWSP}`;
await bot.api.editMessageText(chatId, messageId, touched, { entities });
// забрали views из ответа
await bot.api.editMessageText(chatId, messageId, text, { entities });

Сначала чуть меняем сообщение, читаем счётчик, сразу откатываем. entities оба раза — уже с UTM. Если откат не прошёл, в посте останется невидимый символ. Это неприятно, но лучше, чем молча состричь ссылки.

Для подписи к фото то же самое через editMessageCaption.

Что сознательно не делаем

Не парсим URL из текста. Видимая ссылка и text_link — разные вещи; регулярка по тексту не попадёт в скрытую.

Не копируем пост сделки из канала в канал. У каждой сделки свой dealId и свой день слота. Канон в ЛС — без меток, живой пост — с метками. Источник правды для публикации сделки всегда БД.

Не надеемся, что copyMessages когда‑нибудь получит replace_entities. Пока API такого не даёт.

Не считаем offset в code points. Один розовый кружок в креативе, и Bot API вам это объяснит ошибкой.

Короткий чеклист

  1. Храните креатив как text + entities + file_id, а не только как message_id в ЛС.

  2. Превью — без UTM. Сделка в канале — всегда rebuild с UTM.

  3. Трогайте только text_link с http(s). offset/length не меняйте.

  4. Считайте диапазоны в UTF-16, не режьте суррогаты.

  5. Любой последующий editMessage* должен нести те же помеченные URL, иначе health‑check сотрёт аналитику.

  6. message is not modified ≠ “поста нет”. Это «пост есть, но счётчик вы не получили».

  7. Белый список схем ссылок обязателен, если редактор ходит через HTML.

Ссылки в Telegram удобно прятать. Аналитику из‑за этого приходится собирать руками: не копировать сообщение, а собирать его заново под каждую площадку — и никогда не редактировать «для проверки» сырым каноном.