Почему рекламный пост в Telegram нельзя публиковать через copyMessages
- пятница, 28 августа 2026 г. в 00:00:07
Если бот копирует один и тот же пост в десять каналов, в 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: лучше не трогать: это диплинки в клиента, а не лендинг с аналитикой.
Схема меток, которая нам хватила:
Параметр | Зачем |
|---|---|
| источник в кабинете рекламодателя |
| канал трафика |
| какая кампания |
| какая площадка и какой день слота |
| какая сделка |
Если у канала нет @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.
Креатив хранится дважды.
Канон в ЛС бота: canonicalChatId + canonicalMessageIds. Это «как рекламодатель это видел». Отсюда удобно copyMessages — пиксель в пиксель, включая медиагруппу.
Структурированная копия в БД: 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, который уже нельзя использовать. Пустой пост (нет текста и нет валидного медиа) не отправляем вообще — лучше ретрай, чем «опубликовали дырку» и списали эскроу.

😀 занимает два 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 просмотры поста не отдаёт.
Поэтому смотрим в три источника, по убыванию адекватности:
MTProto‑сессия того же бота (api_id / api_hash + токен) — channels.getMessages, там есть views.
Публичный превью t.me/s/username — только для каналов с @username.
Костыль: 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 вам это объяснит ошибкой.
Храните креатив как text + entities + file_id, а не только как message_id в ЛС.
Превью — без UTM. Сделка в канале — всегда rebuild с UTM.
Трогайте только text_link с http(s). offset/length не меняйте.
Считайте диапазоны в UTF-16, не режьте суррогаты.
Любой последующий editMessage* должен нести те же помеченные URL, иначе health‑check сотрёт аналитику.
message is not modified ≠ “поста нет”. Это «пост есть, но счётчик вы не получили».
Белый список схем ссылок обязателен, если редактор ходит через HTML.
Ссылки в Telegram удобно прятать. Аналитику из‑за этого приходится собирать руками: не копировать сообщение, а собирать его заново под каждую площадку — и никогда не редактировать «для проверки» сырым каноном.