7 августа 2026 г.

Как сделать карточку-превью для фото из Telegram-бота.

Kitty Space начинался как Telegram-бот. Люди присылают фото уличных котов, бот их сохраняет и возвращает ссылку. Дальше всем хотелось одного: поделиться этим котом в Twitter или в чате. Но вставляешь ссылку, а превью нет: голый URL или в лучшем случае карточка без картинки. Дальше о причине и о небольшом сервере, который вокруг этого собрали.

Причина простая, если присмотреться. Социальные платформы не строят карточку из URL картинки или ссылки t.me. Они шлют краулера, который скачивает страницу и читает <meta>-теги из HTML: og:image, og:title и их twitter-аналоги. Фото в объектном хранилище остаётся файлом, а не веб-страницей. На нём нет ни HTML, ни места под эти теги, поэтому платформе и нечего разворачивать.

Почему нужна отдельная страница

К JPEG нельзя прицепить мета-теги. Имеющиеся ссылки вели либо на бота, либо на голую картинку, и ни одна не является HTML-страницей, с которой краулер прочитает теги. Сайта на тот момент тоже не было, только бот и бакет хранилища.

Значит, каждому фото нужна веб-страница, которой пока не существует, и кто-то должен её отдавать. Этим «кем-то» становится небольшой HTTP-сервер с одной ручкой. Вся его задача: взять id фото и вернуть HTML-документ с нужными тегами. А ссылка для шеринга тогда ведёт на этот сервер, а не на картинку.

Поток получается такой:

  1. Ссылка для шеринга это https://…/p/{photo-id}, она ведёт на превью-сервер.
  2. Краулер её скачивает и получает HTML-страницу, у которой og:image указывает на реальное фото на CDN.
  3. Платформа читает теги и строит карточку.

Картинка как лежала на CDN, так и лежит, её никуда не двигают. Новая страница существует только чтобы нести описывающие её теги.

Шаг 1: сервер с одной ручкой

Сервер написан на обычном Go, с единственным хендлером. По id фото он собирает URL картинки на CDN и публичный URL этой страницы, рендерит HTML и отдаёт его:

r.HandleFunc("/p/{id}", func(w http.ResponseWriter, r *http.Request) {
  id := mux.Vars(r)["id"]
  imageURL := fmt.Sprintf("https://%s.%s/photos/%s", bucket, cdn, id)  // the real photo
  pageURL  := publicBaseURL + "/p/" + id                                // this page

  html, _ := gen.GetPreviewHTML(imageURL, pageURL, shouldRedirect)
  w.Header().Set("Content-Type", "text/html; charset=utf-8")
  w.Header().Set("Cache-Control", "public, max-age=31536000")  // a photo's page never changes
  w.Write([]byte(html))
})

Никакого запроса в базу и никакой работы с картинкой тут нет. Одного id достаточно, чтобы знать, где лежит фото, поэтому страница строится и отдаётся за один шаг.

Шаг 2: теги, которые делают карточку

HTML почти целиком состоит из head. Тело краулеру почти безразлично, смысл в тегах:

<meta property="og:title"  content="🐱 Street cat spotted!">
<meta property="og:image"  content="{{.ImageURL}}">   <!-- absolute CDN url -->
<meta property="og:url"    content="{{.PageURL}}">
<meta property="og:type"   content="article">
<meta name="twitter:card"  content="summary_large_image">
<meta name="twitter:image" content="{{.ImageURL}}">

og:image и twitter:image оба указывают на фото на CDN. summary_large_image просит у Twitter раскладку во всю ширину, а не маленькую боковую миниатюру.

Шаг 3: делиться страницей, а не картинкой

Этот шаг легко упустить. Кнопки шеринга в боте должны вести на превью-страницу, а не на фото, поэтому ссылка для шеринга строится вокруг /p/{id}:

func GetPreviewURL(baseURL, imageID string) string {
  return fmt.Sprintf("%s/p/%s", baseURL, imageID)
}

func GetTwitterShareURL(baseURL, text, imageID string) string {
  return fmt.Sprintf(
    "https://twitter.com/intent/tweet?text=%s&url=%s",
    url.QueryEscape(text),
    url.QueryEscape(GetPreviewURL(baseURL, imageID)),   // the page, so the card resolves
  )
}

Теперь кнопка «Поделиться в Twitter» в боте открывает твит, ссылка в котором ведёт на превью-страницу, и карточка подтягивается сама.

Части, где пришлось пробовать и ошибаться

Форма маленькая. Заставить каждую платформу её уважать было медленной частью.

og:image должен быть абсолютным URL на публичном хосте. Относительный путь или ссылка за авторизацией, и карточка приходит пустой. Со ссылкой прямо на CDN картинка грузится быстро и остаётся доступной.

summary_large_image превращает карточку Twitter из крошечной боковой миниатюры в фото на всю ширину. Без него кот размером с почтовую марку.

Страница кэшируется жёстко. Превью фото не меняется, поэтому max-age равен году. Краулеры всё равно перезапрашивают по своему графику, а кэш делает повторные шеры дешёвыми.

Есть и запасной случай. Неизвестный путь всё равно отдаёт валидную страницу с тегами по умолчанию, и опечатка или устаревшая ссылка вырождается в «этот кот не найден», а не в сломанную карточку.

Редирект появился позже

Витрина появилась намного позже, с настоящей страницей-профилем на каждого кота. Заменять превью-сервер не пришлось. Он научился уводить людей дальше, оставляя ботов на месте.

Рычагом служит user agent. Краулер должен остаться на странице, чтобы прочитать теги; человек должен попасть на сам профиль. Поэтому страница делает редирект только когда посетитель не из известных ботов:

func isBot(userAgent string) bool {
  bots := []string{
    "facebookexternalhit", "Twitterbot", "Slackbot",
    "TelegramBot", "LinkedInBot", "WhatsApp", "Googlebot",
  }
  ua := strings.ToLower(userAgent)
  for _, b := range bots {
    if strings.Contains(ua, strings.ToLower(b)) { return true }
  }
  return false
}

shouldRedirect := !isBot(r.UserAgent())

Когда shouldRedirect выставлен, страница добавляет редирект; иначе остаётся на месте для краулера:

{{if .ShouldRedirect}}
<meta http-equiv="refresh" content="0; url={{.RedirectURL}}">
<script>setTimeout(() => location.replace("{{.RedirectURL}}"), 1000);</script>
{{end}}

Бот читает карточку. Человек секунду видит фото, потом попадает на профиль кота.

Приём

Если контент лежит там, где у него нет своей веб-страницы, например в боте или в бакете хранилища, поделиться им с превью как есть не получится. Сработала одна маленькая серверная страница на каждую единицу, с тегами og: и twitter:, которые указывают на реальную картинку, отдаваемая вместо файла, жёстко закэшированная, с редиректом только для людей, добавленным когда появилось более полное место назначения. Этой одной ручки хватило, чтобы любая ссылка разворачивалась в карточку.

Kitty Space живёт на kitty.tinygods.dev.