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

lcehtatau27uulddjbkina7uzca.jpeg


Все началось с одной из учебных групп в 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 (), что позволяет загрузить аватарку в память без использования промежуточных файлов.


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

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


image-loader.svg

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

Для рисования используем библиотеку 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, в моем случае 10 px;
  • имя отправителя — 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.

image-loader.svg

© Habrahabr.ru