python

Telegram бот на Firebase

  • вторник, 27 апреля 2021 г. в 00:32:37
https://habr.com/ru/post/554132/
  • Python
  • Программирование
  • Google API
  • Облачные сервисы
  • Serverless


В основном, про Firebase рассказывают в контексте создания приложений под IOS или Android. Однако, данный инструмент можно использовать и в других областях разработки, например при создании Telegram ботов. В этой статье хочу рассказать и показать насколько Firebase простой и удобный инструмент (а ещё и бесплатный, при разумных размерах проекта).


Motivation

Ровно посередине апреля я очнулся от работы и вдруг вспомнил, что у меня ещё не написан диплом, а сдавать его через месяц - полтора. У меня была заброшенная научная статья на достаточно скучную тему, делать на её базе диплом мне уж совсем не хотелось. Нужен был глоток свежего воздуха - какой-нибудь новый проект.

В середине февраля я с ребятами из веб студии обсуждал идею создания приложения по подбору квартир с рекомендательной системой, которая анализировала бы изображения интерьеров и подстраивалась под предпочтения пользователя. Так как мой диплом должен быть на тему Computer Vision, то я решил развить эту тему. Да, и было придумало прикольное название - Flinder (Flats Tinder).

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

В частности, меня вдохновила одна научная статья про DeViSE: A Deep Visual-Semantic Embedding Model. Мне было интересно попробовать такие эмбединги.

В чём суть?

Если кратко, то авторы статьи обучили нейронную сеть предсказывать не конкретные классы изображений, по типу "кошка", "собака", а векторные представления названий классов. Это те самые векторные представления, для которых "King - Man + Woman = Queen".

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

Какого бота я делал?

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

Итак, телеграм бот:

  • Присылает пользователю изображение и просит его оценить

  • Получает оценку от пользователя

  • Сохраняет оценку пользователя в базу данных

  • *киллер фича* - удаляет изображение из диалога, если оно не понравилось пользователю

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

Да, также важно раздобыть контент, который пользователи будут оценивать. Немного заморочившись я скачал сразу 20.000 изображений с интерьерами с Pinterest. Это были и запросы как «скандинавский интерьер квартиры» так и «готический интерьер дома». Старался собрать как можно более разнообразный (репрезентативный) набор изображений.

Изображения добывал с помощью библиотечки pinterest-image-scraper (Там есть баги и она не супер удобная, но мне её хватило).

Firebase

Меня немного смущал момент отправки изображений телеграм ботом. Получившаяся база изображений в 20.000 штук весила примерно 1.5 гигабайта и мучаться с переносом её на сервер мне уж совсем не хотелось.

Тут я подумал о том, что было бы классно выложить все фотографии на какой-нибудь облачный сервис, и дальше в телеграмм боте использовать только ссылки на изображения, а не сами изображения. А ссылки на изображения (или идентификаторы изображений) и оценки пользователей можно хранить в Firebase Realtime Database.

Перед этим я быстро проверил, поддерживает ли библиотека pyTelegramBotAPI отправку изображений ссылками. Метод send_photo спокойно работал с ссылками на изображения, так что я решил дальше использовать этот подход.

Прежде я работал только с Firebase Realtime Database, но про удобство Firebase Storage был наслышан.

Инициализация проекта в Firebase

Итак, чтобы начать работать с Firebase вам необходимо зарегистрироваться на этом сервисе и создать там проект. После чего у вас откроется вкладка Project Overview.

Project Overview
Project Overview

Для того чтобы получить доступ к функциям Firebase из кода необходимо скачать ключи доступа к проекту. Сделать это можно нажав на значок шестерёнки в верхнем левом углу, справа от надписи Project Overview, и выбрать пункт Project Settings. Затем, на открывшемся экране нужно выбрать Service Accounts и нажать Generate new private key.

После чего, выбрав в левой вкладке меню Realtime Database и Storage можно создать соотвествующие базы данных. В проект на питоне подключить эти базы данных можно следующим способом.

import firebase_admin
from firebase_admin import credentials
from firebase_admin import db
from firebase_admin import storage

cred = credentials.Certificate("/path/to/secret/key.json")

default_app = firebase_admin.initialize_app(cred, {
	  'databaseURL': 'https://realtime-db-name',
    'storageBucket' : 'storage-bucket-local-name'
	})

bucket = storage.bucket()

Где правые части внутри выражения initialize_app есть условные ссылки на названия ваших баз данных внутри проекта в Firebase. После инициализации у вас будут доступны две базы данных

  • db - объект Realtime Database. Данные хранятся в виде одного JSON дерева. В случае работы с питоном - это по сути объект dict.

  • bucket - объект Storage, по сути, обёртка над Google Storage, позволяющая по API загружать и скачивать объекты.

Далее покажу несколько примеров использования этих двух баз данных.

Firebase Realtime Database

Обожаю эту базу данных и готов петь ей дифирамбы. Она очень удобная, быстрая, надёжная, а главное - никакого SQL! Это JSON based Database. Но хватит похвалы, давайте посмотрим, как с ней работать.

Например, у нас есть несколько пользователей, которые хранятся в users_database.

users_database = {
	"1274981264" : {
		"username" : "user_1",
		"last_activity" : 1619212557
		},
	"4254785764" : {
		"username" : "user_2",
		"last_activity" : 1603212638
		}
}

Добавить их в в Realtime Database мы можем так:

db.reference("/users_database/").set(users_database)

Также мы можем добавить и следующего пользователя таким вот образом:

user_3_id = "2148172489"
user_3 = {
	"username" : "user_3",
	"last_activity" : 1603212638
}

db.reference("/users_database/" + user_3_id).set(user_3)

Этот код добавит user_3 в users_database

Получить данные можно так.

user_3 = db.reference("/users_database/" + user_3_id).get()

users_database = db.reference("/users_database/").get()

Это вернет объекты формата Python dict

Стоит отметить, что массивы в Realtime database хранятся в следующем виде.

a = ["one", "two", "three"]

firebase_a = {
	"0" : "one",
	"1" : "two",
	"2" : "three"
}

То есть также в формате json

И ещё один нюанс, Realtime Database не хранит объекты None и [] То есть код

db.reference("/users_database/" + user_3_id).set(None)

Приведёт к ошибке

А код

db.reference("/users_database/" + user_3_id).set([])

Удалит данные user_3

Также стоит добавить, что если внутри вашего объекта в питоне есть какое-либо поле, значение которого есть None или [], то в объекте, загруженном в Realtime Database этих полей не будет. То есть:

user_4 = {
	"username" : "user_4",
	"last_activity" : 4570211234,
  "interactions" : []
}

# Но 
user_4_in_fb = {
	"username" : "user_4",
	"last_activity" : 4570211234
}

На самом деле, методами get() и set() всё не ограничивается. По ссылке вы можете посмотреть документацию по firebase_admin.db

Firebase Storage

Вернёмся к Firebase Storage. Допустим, у нас на локальном диске хранится изображение по пути image_path Следующий код добавит это изображение в Storage.

def add_image_to_storage(image_path):
    with open(image_path, "rb") as f:
        image_data = f.read()

    image_id = str(uuid.uuid4())
    
    blob = bucket.blob(image_id + ".jpg")
    
    blob.upload_from_string(
        image_data,
        content_type='image/jpg'
    )

Где image_id - уникальный идентификатор изображения.

С получением доступа к изображению всё чуточку сложнее. blob имеет формат

blob.__dict__
blob.__dict__ = {'name': 'one.jpg',
 '_properties': {'kind': 'storage#object',
  'id': 'flinder-interiors/one.jpg/1619134548019743',
  'selfLink': 'https://www.googleapis.com/storage/v1/b/flinder-interiors/o/one.jpg',
  'mediaLink': 'https://storage.googleapis.com/download/storage/v1/b/flinder-interiors/o/one.jpg?generation=1619134548019743&alt=media',
  'name': 'one.jpg',
  'bucket': 'flinder-interiors',
  'generation': '1619134548019743',
  'metageneration': '1',
  'contentType': 'image/jpg',
  'storageClass': 'REGIONAL',
  'size': '78626',
  'md5Hash': 'OyY/IkYwU3R1PlYxeay5Jg==',
  'crc32c': 'VfM6iA==',
  'etag': 'CJ+U0JyCk/ACEAE=',
  'timeCreated': '2021-04-22T23:35:48.020Z',
  'updated': '2021-04-22T23:35:48.020Z',
  'timeStorageClassUpdated': '2021-04-22T23:35:48.020Z'},
 '_changes': set(),
 '_chunk_size': None,
 '_bucket': <Bucket: flinder-interiors>,
 '_acl': <google.cloud.storage.acl.ObjectACL at 0x7feb294ff410>,
 '_encryption_key': None}

Где есть selfLink и mediaLink, однако доступ к изображению по этим ссылкам - ограничен и доступен только при наличии определенных прав доступа, которые настраиваются в консоли Firebase.

В своём проекте я постарался сделать всё максимально просто и поэтому воспользовался методом blob.generate_signed_url(...). Этот метод генерирует ссылку, которая имеет определённое время жизни. Время жизни ссылки является параметром метода.

Следующий метод генерирует ссылку, живущую 10 минут.

def get_image_link_from_id(image_id):
    blob = bucket.blob(image_id + ".jpg")
    time_now = int(time.time() // 1)
    ttl = 600
    return blob.generate_signed_url(time_now + ttl)

Telegram Bot

Не буду вдаваться в подробности написания телеграм ботов, так как на эту тему статей много (простой туториал, супер подробная статья). Пройдусь только по основным моментам.

Как выглядит бот?

В своём проекте на pyTelegramBotAPI я использовал InlineKeyboardButton, состоящую из эмоджи и callback_query_handler, обрабатывающий нажатия на кнопки.

keyboard = types.InlineKeyboardMarkup(row_width = 3)
nott = types.InlineKeyboardButton(text="no_emoji", callback_data='no')
bad = types.InlineKeyboardButton(text="bad_emoji", callback_data='bad')
yes = types.InlineKeyboardButton(text="yes_emoji", callback_data='yes')
keyboard.add(nott, bad, yes)
Небольшой баг хабра

Пока писал статью столкнулся с тем, что редактор статей Хабр в браузере Safari не переваривает эмоджи внутри вставок с кодом. Если что, в моём боте кнопки имеют такой вот вид, ниже скрин кода.

Реакции пользователей я храню в Realtime Database. Добавляю их туда следующим образом.

def push_user_reaction(chat_id, image_id, reaction):
  db_path = "/users/" + str(chat_id)+ "/interactions/"+ str(image_id)
	db.reference(db_path).set(reaction)

База данных Firebase Realtime Database имеет следующий вид

users - база данных пользователей.

Для каждого пользователя в разделе interactions мы храним взаимодействия пользователя с изображениями. last_image_id и last_message_id - элементы логики работы телеграмм бота. Что-то типо конечного автомата.

Да, и идентификаторы пользователей в базе данных - это telegram id пользователей (chat_id для библиотеки telebot).

users
users
interactions
interactions

interiors_images - база идентификаторов изображений. В ней хранятся метаданные изображений, а также дублируются реакции пользователей.

interiors_images
interiors_images

Ну и images_uuids в Realtime Database - это просто массив с уникальными идентификаторами изображений. Своего рода костыль, чтобы было откуда выбирать идентификаторы изображений.

Костыль

Собственно говоря сам костыль.

IMAGES_UUIDS = None

def obtain_images_uuids():
    global IMAGES_UUIDS
    IMAGES_UUIDS = db.reference("/images_uuids/data").get()

obtain_images_uuids()

def get_random_image_id():
    image_id = np.random.choice(IMAGES_UUIDS)
    return image_id

Да, на первом этапе, изображения, отправляемые пользователю, выбираются случайным образом.

Firebase Storage выглядит таким вот образом. Можно заметить, что названия изображений в Storage есть просто идентификаторы изображений + их расширение.

Также, огромным плюсом облачных баз данных является то, что можно хранить состояния взаимодействия пользователя с ботом. И если вдруг сервер решит перезапустить код бота, то это состояние сохранится. (Хотя если пользоваться обычной базой данных состояние тоже сохранится, так что это такой, притянутый за уши плюс)

Вообще, из недостатков библиотеки pyTelegramBotAPI - это то, что она медленная. И под напором сообщений от большого количества пользователей что-то вполне может пойти не так. Так что в будущем я планирую использовать другую, более мощную библиотеку. Проект в нынешнем виде - это буквально один вечер работы и поэтому я использовал знакомые мне инструменты, с которыми я уже работал.

Деплой на сервер

Последнее время я стал адептом докера, поэтому и этот свой проект я размещал на сервер с помощью докера. Пройдусь по основным моментам, которые я использовал в проекте.

Dockerfile

FROM python:buster

COPY requirements.txt /tmp/
RUN pip install -r /tmp/requirements.txt

RUN mkdir /src
WORKDIR /src

COPY ./src .
CMD python3 /src/code/bot.py
requirements.txt
pyTelegramBotAPI
firebase-admin
google-cloud-storage
numpy

Где src - это место монтирования docker volume, который я создал до этого командой

docker volume create \
            --opt type=none \
            --opt o=bind \
            --opt device=/home/ubuntu/Flinder/src \
            --name flinder_volume

После чего собрал образ и запустил контейнер следующим образом, где флаг-v монтирует созданные ранее flinder_volume в директорию src внутри докер контейнера.

docker run -d \
				--network=host \
				--name flinder_bot \
				--restart always \
				-v "flinder_volume:/src" devoak/flinder:1.0

Ну и полезное замечание, что у команды docker run можно указать прекрасный параметр --restart always, который обеспечит постоянную работу бота на сервере.

До этого я делал через systemctl, что было сложнее и менее удобно.

Заключение и капелька пиара

Flinder - именно так называется мой проект (Flats Tinder)
Flinder - именно так называется мой проект (Flats Tinder)

Надеюсь, моя статья была полезна. Хочу привнести в сообщество программистов такую идею, что работать с Firebase - это очень просто, приятно и удобно, а главное - бесплатно, при разумных размерах проекта.

Более того, использование Firebase не ограничивается Телеграм ботами, недавно я сделал целый промышленный парсер инстаграмма на основе Firebase Realtime Database, о чём я тоже планирую написать статью.

Касательно моего проекта, буду благодарен, если вы повзаимодейтсвуете с моим ботом, там собраны классные фотографии интерьеров. Несколько часов сидел на Пинтересте и выбирал наиболее эстетичные категории. Добавил даже интерьеры квартир с двойным светом и интерьеры домов в стиле шале. С помощью бота планирую собрать данные для того чтобы проверить гипотезу о том, что эмбединги DeViSE работают эффективнее обычных эмбедингов изображений в контексте рекомендательных систем.

По поводу других проектов, оставлю свой телеграм канал. Периодически пишу туда про проекты, которыми занимаюсь (а их много, есть даже нейросети для театра). Более того, если вам вдруг нужен сайт или приложение с продвинутым бэкендом, то милости прошу, моя веб студия. Занимаемся проектами любой сложности.