Генерация стикеров из сообщений через Bot API

Все началось с одной из учебных групп в Telegram. Студенты там очень любят делать стикеры из сообщений своего преподавателя. Я выяснил, что делаются они в полуавтоматическом режиме: сообщение пересылается в бота, который рисует «пузырек» сообщения, а результат пересылается в официального стикер-бота.

Схема рабочая, но напрашивается идея минимизировать количество пересылок. Тем более, что в Telegram существуют боты, создающие пользовательские стикер-паки. Рассказываю, как сделать такого бота без лишних телодвижений, и даю свое творение на тест. Если не хотите запариваться с созданием бота, но не против запечатлеть парочку своих золотых цитат для потомков, — прошу под кат.
Концепт идеи прост: пользователь пересылает сообщение в диалог с ботом, бот создает стикер.

Чтобы было интереснее, введем дополнительные ограничения:

  • никаких баз данных, даже встроенных;
  • никаких промежуточных файлов, стараемся делать все в памяти.

Такой подход усложняет разработку бота, но значительно упрощает его эксплуатацию:

  • вся информация хранится в Telegram, у бота нет данных — не нужно думать о резервном копировании;
  • для запуска бота нужен только код и файл конфигурации;
  • бот может быть запущен даже на Raspberry Pi (кстати, сервер с этим одноплатником можно получить в Selectel в течение часа).

Для разработки я выбрал язык Python версии 3.8. Сперва сделаем основу бота, которая получает сообщения и выводит доступную информацию.

Основа

Итак, регистрируем нового бота или используем старого. Все операции с ними производятся через официального BotFather. Для начала хватит идентификатора бота (username) и токена для API.

Представленный в статье код адаптирован для объяснения в контексте статьи. Ссылка на оригинальный исходный код будет в конце.

Для Bot API уже есть обертка, названная python-telegram-bot. В статье используется версия 13.4.1. Создаем простой обработчик текстовых сообщений:

def on_message_received(update: Update, context: CallbackContext): # Игнорируем все события, кроме получения сообщения if not update.message: return # Если идентификатор чата не равен идентификатору отправителя, # то бота включили в группу. Игнорируем. if update.message.chat_id != update.message.from_user.id: return # Синтетическое ограничение: хотим работать только с пересланными сбщ if not update.message.forward_from: update.message.reply_text("Only forwarded messages supported!") return print(update.message)

Создаем бота и регистрируем обработчик.

import toml
from telegram.ext import Updater, MessageHandler, Filters config = toml.load('dsb.toml') bot = Updater( token=config["telegram"]["token"]
)
bot.dispatcher.add_handler( MessageHandler(Filters.update.message, on_message_received)
)
bot.start_polling()
bot.idle()

Теперь боту можно переслать любое сообщение, и он выведет в stdout данные, которые ему доступны.

Вывод обработчика сообщений без чувствительных данных

{ 'message_id': 391, 'date': 1640260315, 'chat': { 'id': 00000001, 'type': 'private', 'username': 'someone-s-username', 'first_name': 'Пример', 'last_name': 'Примерыч' }, 'forward_from': { 'id': 0000002, 'first_name': 'Иван', 'is_bot': False, 'last_name': 'Иваныч', 'username': 'totally-not-a-bot', 'language_code': 'en' }, 'forward_date': 1640259241, 'text': 'пример!', 'entities': [], 'caption_entities': [], 'photo': [], 'new_chat_members': [], 'new_chat_photo': [], 'delete_chat_photo': False, 'group_chat_created': False, 'supergroup_chat_created': False, 'channel_chat_created': False, 'from': { 'id': 00000001, 'type': 'private', 'username': 'someone-s-username', 'first_name': 'Пример', 'last_name': 'Примерыч', 'language_code': 'ru' }
}

В представленном выводе доступна следующая информация:

  • forward_from — информация об авторе пересланного сообщения;
  • text — текст пересланного сообщения.

Для того, чтобы нарисовать «пузырек» сообщения, не хватает лишь аватарки. Получаем ее парой отдельных вызовов:

# получаем первую (текущую) аватарку пользователя
result = context.bot.get_user_profile_photos( update.message.forward_from.id, limit=1
) # type: UserProfilePhotos # Обрабатываем ситуацию, когда аватарки нет, или она скрыта настройками приватности
if result.total_count > 0: file = context.bot.get_file(result.photos[0][0].file_id) # type: File

Вызов get_user_profile_photos() возвращает двумерный массив записей типа File. Первое измерение задает количество аватарок у пользователя, но не больше limit. Второе измерение задает аватарку разных размеров. В нашем случае достаточно забрать первую попавшуюся картинку, но для оптимизации стоит сразу выбирать картинку подходящего разрешения.

Объект file имеет метод download_as_bytearray(), что позволяет загрузить аватарку в память без использования промежуточных файлов.

Теперь, когда есть необходимая информация, можно нарисовать «пузырек».

Рисуем стикер

Пример созданного изображения

Для рисования используем библиотеку Pillow версии 8.4.0. Шрифт — OpenSans, такой же используется в официальных приложениях Telegram.

Мессенджер накладывает ограничение на стикеры: как минимум одна сторона должна быть размером 512 пикселей. Так как мы генерируем сообщение, то можно зафиксировать ширину, а высоту рассчитывать в зависимости от количества текста.

# Импортируем шрифт, кегль 26
OPEN_SANS = ImageFont.truetype('OpenSans.ttf', 26) # Разбиваем сообщение на строки из расчета, # что в одной строке не больше 30 символов
text = textwrap.wrap(update.message["text"], width=30) # Получаем высоту шрифта
font_height = OPEN_SANS.getsize(text[0])[1] # Рассчитываем высоту картинки
height = font_height * (len(text) + 1) + 2*BUBBLE_PADDING if height > 512: raise OverflowError("Image too big")

Функция textwrap.wrap() разбивает строку на массив строк, пытаясь сделать перенос по пробелам. Расчет высоты картинки прост:

  • отступ от начала — BUBBLE_PADDING, в моем случае 10px;
  • имя отправителя — font_height;
  • сообщение — font_height * len(text);
  • отступ до конца — BUBBLE_PADDING.

Если сообщение большое, то высота картинки может получиться больше 512 пикселей. В этом случае наши полномочия — лапки, выбрасываем исключение. Если размер меньше, то можем продолжать. Проверяем наличие аватарки у пользователя и адаптируем ее к нашему стикеру.

# Скачиваем аватарку как массив байт
data = file.download_as_bytearray()
# Класс Image из Pillow умеет читать только из потоков,
# создаем виртуальный байтовый поток
avatar = Image.open(BytesIO(data)) # type: Image.Image # Аватарки в Телеграме квадратные, поэтому просто масштабируем
# до желаемого размера
size = (AVATAR_SIZE, AVATAR_SIZE)
avatar = avatar.resize(size, Image.ANTIALIAS) # Создаем круглую маску
mask = Image.new('L', size, 0)
draw = ImageDraw.Draw(mask)
draw.ellipse((0, 0) + size, fill=255) # Заполняем прозрачным по маске
avatar = ImageOps.fit(avatar, mask.size, centering=(0.5, 0.5))
avatar.putalpha(mask)

Теперь у нас есть сообщение и аватарка. Создаем «холст» и начинаем рисовать. Обязательно выбираем цветовой режим RGBA и делаем прозрачный (alpha = 0) основным цветом «холста».

# Создаем изображение
img = Image.new('RGBA', (width, height), color=(255, 255, 255, 0)) # Создаем холст, на котором рисуем
d = ImageDraw.Draw(img) # Если есть аватарка – вставляем, если нет – рисуем синий круг
if avatar: img.paste(self.avatar, (0, 0))
else: d.ellipse((0, 0, AVATAR_SIZE, AVATAR_SIZE), fill="blue") # Рисуем черный пузырек
d.rounded_rectangle((BUBBLE_X_START, 0, width, height), fill="black", radius=BUBBLE_RADIUS) # Первая строка – розовый заголовок, имя
d.text( (TEXT_X_START, BUBBLE_PADDING), update.message.forward_from.first_name, fill="pink", font=OPEN_SANS
) # Вторая и последующие строки – текст сообщения
offset = BUBBLE_PADDING + font_height
for line in self._text: d.text((TEXT_X_START, offset), line, fill="white", font=OPEN_SANS) offset += font_height

Финальный штрих — сохранить изображение. Так как мы все держим в памяти, то сохраняем также в виртуальный байтовый поток.

sticker = BytesIO()
# Для прозрачности сохраняем в PNG
img.save(sticker, 'PNG') # Отматываем поток на начало, чтобы из него можно было считать
sticker.seek(0);

Осталось совсем немного: загрузить стикер в Telegram и передать его пользователю.

Заполнение набора стикеров

Те, кто создавал собственные наборы, знают, что для всех операций со стикерами необходимо обращаться к боту Stickers. Однако, в Bot API есть набор вызовов для взаимодействия со стикерами, в том числе функция создания набора. Созданный ботом набор стикеров имеет следующие особенности:

  • уникальное имя набора (используется в ссылках вида https://t.me/addstickers/<имя>) обязательно должно заканчиваться на _by_%BOT_USERNAME%;
  • набор стикеров принадлежит пользователю и может быть отредактирован через бота Stickers;
  • для управления набором стикеров через бота требуется его уникальное имя и идентификатор пользователя.

Как упоминалось ранее, бот должен работать без базы данных. Таким образом, уникальное имя набора должно быть вычисляемым. Самый простой способ — использовать идентификатор пользователя в имени набора. Однако это некорректно: любой пользователь набора стикеров может «вычислить» автора.

Имя бота в уникальном имени набора неявно используется для аутентификации действий бота. Так, зная идентификатор пользователя, произвести деструктивные действия с набором не получится.

Эта «особенность» исправляется хэшированием. Мне показалось подходящим использовать UUIDv5, который использует SHA-1 для хэширования. Правда, UUIDv5 не соответствует сразу двум ограничениям Telegram:

  • может начинаться с цифры;
  • имеет запрещенные символы — дефисы.

Первая проблема решается префиксом, а вторая — удалением запрещенных символов. Таким образом, UUIDv5 от идентификатора пользователя — отличное вычисляемое решение. А чтобы усложнить угадывание автора, можно добавить «соль» к идентификатору.

# id пользователя + соль
sid = f"{update.message.from_user.id}-{context.bot_data.get('salt', '')}"
# Генерируем uuidv5 и конвертируем в строку
uid = str(uuid.uuid5(uuid.NAMESPACE_X500, sid))
# Удаляем дефисы uid = uid.replace("-", "")
# В качестве буквенного префикса используем s
sticker_set_name = f"s{uid}_by_{context.bot_data['name']}"

Теперь у нас все есть, создаем набор с первым стикером.

context.bot.add_sticker_to_set( user_id=update.message.from_user.id, name=sticker_set_name, emojis=DEFAULT_EMOJI, png_sticker=bio
)

Если функция вернула True, то стикерпак создан. Если мы хотим добавить еще один стикер, то сперва набор нужно найти.

# get_sticker_set выбросит исключение, если набора нет.
# Это можно использовать для определения, когда нужно создать набор.
sticker_set = context.bot.get_sticker_set(sticker_set_name) # type: StickerSet # Наборы ограничены по 120 стикеров
if len(sticker_set.stickers) >= 120: update.message.reply_text("Sticker set is full") return # Добавляем!
context.bot.add_sticker_to_set( user_id=update.message.from_user.id, name=sticker_set_name, emojis=DEFAULT_EMOJI, png_sticker=bio
)

Стикеры добавляются в набор мгновенно, но у пользователей отображаются в течение нескольких часов. Наиболее оперативный способ обновить набор — удалить из сохраненных наборов и добавить заново.

В качестве ответа бот будет отправлять только что добавленный стикер, подтверждая, что он действительно загружен.

sticker_set = context.bot.get_sticker_set(sticker_set_name) # type: StickerSet
update.message.reply_sticker(sticker_set.stickers[-1])

Вот и все, бот готов.

Конечно, это далеко не продуктовый вариант, так как Emoji не поддерживаются, существует ограничение на 120 стикеров на человека и совершенно нет кастомизации сообщений. Но для начала сойдет.

Заключение

Еще один маленький шажок для автоматизации рутинных процессов. Генерация стикеров — не самый популярный случай, но, если вдруг захочется автоматизировать, теперь вы знаете как.

Для быстрого тестирования можете использовать моего бота: ohmyquotebot (если что, он не будет жить вечно). Бот не отвечает на команду /start, так что не волнуйтесь и просто пересылайте ему сообщение, из которого хотите сделать стикер.

Исходный код доступен на GitHub.

Читайте так же:

  • В Яндекс.Директе теперь отражаются все события в вебе и приложенияхВ Яндекс.Директе теперь отражаются все события в вебе и приложениях В Яндекс.Директе теперь отображаются данные о действиях пользователей после перехода с рекламы со всех каналов. Это помогает строить сквозные отчеты и анализировать все источники конверсий. Как использовать полученные данные: Отслеживать эффект от рекламы во всех каналах; Настраивать […]
  • Яндекс представил новую версию своего БраузераЯндекс представил новую версию своего Браузера Яндекс представил новую версию Браузера. Теперь пользователи могут выбрать его внешний облик, разделить экран на две части, быстро сделать скриншот. А значит, они смогут решать одновременно несколько задач: например, в одной части экрана искать в интернете, а в другой — общаться в чате. […]
  • В Google Merchant Center появился новый статус «Требуется доработка сайта»В Google Merchant Center появился новый статус «Требуется доработка сайта» Команда Google Merchant Center объявила о запуске нового статуса «Требуется доработка сайта». Он выводится в том случае. Если сайт не соответствует требованиям сервиса. В частности, на сайте не должно быть неполных сведений, неточной или вводящей в заблуждение информации о товарах. […]
  • Найден способ проверки результатов работы квантового компьютераНайден способ проверки результатов работы квантового компьютера Группа ученых из Вены, Инсбрука, Оксфорда и Сингапура представила алгоритм проверки вычислений квантового компьютера. Способ основан на принципе перекрестных вычислений и позволяет находить ошибки в расчетах, которые невозможно обнаружить с помощью классических суперкомпьютеров.Суть […]