В двух предыдущих статьях я рассказала, как быстро создать инфраструктуру для диалогового бота на основе Yandex Serverless Functions и базы данных YDB, а также показала, как реализовать новые команды, добавив код в шаблон. В качестве примера использовался примитивный бот, реализованный на Python в моём репозитории ydb_serverless_telegram_bot.

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

Все статьи цикла

  1. Создаём основу для диалогового Телеграм бота в облаке - как поднять инфраструктуру для бота за 30 минут

  2. Просто добавь команд: как реализовать диалоговый Телеграм бот на основе шаблона - если вы просто хотите воспользоваться шаблоном, не вникая в детали, вам сюда!

  3. — вы здесь —

Напоминание - что умеет делать бот

С реализацией бота можно познакомиться по ссылке - YDB serverless example.

Бот поддерживает 4 основные команды:

  • /start - показать приветственное сообщение

  • /register - “зарегистрировать” пользователя, т.е. спросить пошагово его имя, фамилию, возраст и сохранить в базу

  • /show_data - показать данные, которые сохранены в базе про текущего пользователя

  • /delete_account - спрашивает подтверждение, что пользователь действительно хочет удалить аккаунт, и, если пользователь подтверждает, удаляет все данные про него из базы данных

Дополнительно во время регистрации доступна команда /cancel, прерывающая процесс.

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

Схема работы бота:

Структура кода

Разберём, как логика такого простого бота реализована на Python 3 с помощью библиотеки TeleBot.

Взаимодействие между Телеграмом и функцией

Сначала давайте разберёмся в том, что происходит, после того, как пользователь совершает какое-либо действие с ботом.

Когда боту приходит любое сообщение или команда, Телеграм отправляет запрос с содержимым согласно вебхуку, который мы настроили командой setWebhook в первой статье. В нашем случае Телеграм оповещает о событии API шлюз Яндекс Облака. API шлюз в свою очередь запускает функцию и передаёт ей на вход данные запроса от Телеграма.

При создании версии функции мы настроили входную точку в python код - python-функция handler в файле index.py. Запрос от Телеграма передаётся в handler аргументом под названием event.

Так как аргументы входной python-функции в шаблоне залогированы, после отправки команды боту во вкладке Логи функции можно посмотреть, какие именно запросы поступают - достаточно отфильтровать логи по уровню логирования DEBUG и тексту лога.

Тело запроса передаётся для обработки предварительно созданному объекту класса TeleBot, который для простоты будем далее называть просто “бот”.

message = telebot.types.Update.de_json(event["body"])
bot.process_new_updates([message])

В процессе обработки сообщений бот совершает действия, которые подразумевает его реализация, после этого функция возвращает ответ API шлюзу и засыпает.

Верхнеуровневая логика обработки сообщений функцией полностью реализована в файле index.py:

import os

import telebot

from bot.structure import create_bot
from database.ydb_settings import get_ydb_pool
from logs import logger

YDB_ENDPOINT = os.getenv("YDB_ENDPOINT")
YDB_DATABASE = os.getenv("YDB_DATABASE")
BOT_TOKEN = os.getenv("BOT_TOKEN")


def handler(event, _):
    logger.debug(f"New event: {event}")

    pool = get_ydb_pool(YDB_ENDPOINT, YDB_DATABASE)
    bot = create_bot(BOT_TOKEN, pool)

    message = telebot.types.Update.de_json(event["body"])
    bot.process_new_updates([message])
    return {
        "statusCode": 200,
        "body": "!",
    }

Инициализацию бота из файла bot/structure.py и подключение в базе данных из database/ydb_settings.py подробно разберём далее.

Инициализация бота и обработка пользовательских сообщений - базовый способ

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

Бот задаётся с помощью токена доступа, полученного от BotFather.

import os
import telebot


bot = telebot.TeleBot(os.environ.get("BOT_TOKEN"))

Далее задаются правила обработки входных данных. Бот обрабатывает команды по заданным правилам, которые обычно выглядят так:

def handler_function(message, bot):
    pass # do something


bot.register_message_handler(
    handler_function,
    commands=["start", "help"], # ...
    pass_bot=True
)

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

  • Типы содержимого сообщения (текст, фото, видео, …)

  • Список команд, содержащихся в сообщении

  • Регулярное выражение, которому должно удовлетворять сообщение

  • Функция-фильтр

  • Типы чатов, в которых появилось сообщение

Подробнее про функцию register_message_handler и её аргументы можно прочитать в документации.

Сообщение, которое не подошло ни под одно правило, игнорируется.

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

@bot.message_handler(commands=["start", "help"])
def handler_function(message):
    pass # do something

Проблема контекста сообщений

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

Например, бот, который мы рассматриваем в этой статье, в рамках разных сценариев ожидает от пользователя в свободной форме ввести свои имя, затем фамилию, затем возраст, а также ответ на вопрос "Уверены ли вы, что хотите удалить аккаунт" формально тоже является обычным текстовым сообщением (и может быть введено в свободной форме), несмотря на наличие вариантов ответов. Каждое из этих сообщений от пользователя подразумевает особенную реакцию от бота. Ни типом контента, ни регулярным выражением разделить эти сообщения не получится.

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

CREATE TABLE `states`
(
  `user_id` Uint64,
  `state` Utf8,
  PRIMARY KEY (`user_id`)
);

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

Вернёмся к примеру команды /register бота-шаблона. Для простоты рассмотрим ещё более ограниченный пример, когда бот должен сначала спросить имя пользователя, а затем фамилию - и остановиться на этом. Также предположим, что уже реализованы функции:

  • get_status(user_id: int) -> str для получения статуса из базы

  • set_status(user_id: int, status: str) -> None для записи статуса в базу 

  • clear_status(user_id: int) -> None для сброса статуса

Тогда каркас кода может выглядеть так:

def handle_first_name(message, bot):
    first_name = message.text
    # do something with first_name
    
    set_status(message.from_user.id, 'last_name')
    bot.send_message(message.chat.id, 'Type your last name:')


def handle_last_name(message, bot):
    last_name = message.text
    # do something with last_name

    clear_status(message.from_user.id)


@bot.message_handler(commands=['register'])
def handle_register(message, bot):
    set_status(message.from_user.id, 'first_name')
    bot.send_message(message.chat.id, 'Type your first name:')
    

@bot.message_handler(content_types=['text'])
def handle_text_message(message, bot):
    user_status = get_status(message.from_user.id)
    
    if user_status == 'first_name':
        handle_first_name(message, bot)
    elif user_status == 'last_name':
        handle_last_name(message, bot)

При вызове команды /register пользователю проставляется статус first_name, что означает, что следующее текстовое сообщение должно содержать имя пользователя - и при его получении вызывается функция handle_first_name. В ней статус меняется на last_name, поэтому следующее текстовое сообщение содержит фамилию и обрабатывается функцией handle_last_name.

С одной стороны, проблема контекста решена, с другой, если от пользователя ожидается много разнообразных текстовых ответов, большая часть логики будет сконцентрирована в функции handle_text_message, что существенно затруднит поддержку бота и читаемость кода. Также было бы удобно передавать от обработчика к обработчику не только сам статус, но и дополнительную информацию, например, значения для имени и фамилии.

Как этого можно добиться?

Инициализация бота и обработка пользовательских сообщений в контексте с помощью стейтов

На помощь приходят фильтры по пользовательскому статусу, реализованные в библиотеке TeleBot.

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

import os
from telebot import TeleBot, custom_filters
from telebot.storage.base_storage import StateStorageBase


BOT_TOKEN = os.getenv("BOT_TOKEN")


class YourStateStorage(StateStorageBase):
    def __init__(self): # additional arguments
        pass # implement storage

      
state_storage = YourStateStorage() 
bot = TeleBot(BOT_TOKEN, state_storage=state_storage)

# ... register handlers ...

bot.add_custom_filter(custom_filters.StateFilter(bot))

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

Разберём всё по порядку: от преимуществ такого подхода до того, как реализовать класс хранилища.

Синтаксис назначения стейтов и фильтрации

После инициализации бота с хранилищем стейтов станут доступны методы объекта bot для выставления и очистки стейтов.

bot.set_state(user_id: int, state: State, chat_id: int) -> None
bot.delete_state(user_id: int, chat_id: int) -> None

Здесь State - это класс для определения одного из возможных состояний, реализованный в библиотеке TeleBot. Их удобно объединять в класс, наследованный от StatesGroup, для группировки по сценарию:

from telebot.handler_backends import State, StatesGroup


class RegisterState(StatesGroup):
    first_name = State() # state for choosing first name
    last_name = State() # state for choosing last name

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

Теперь сценарий регистрации выглядит так:

from telebot.handler_backends import State, StatesGroup


class RegisterState(StatesGroup):
    first_name = State() # state for choosing first name
    last_name = State() # state for choosing last name
    

@bot.message_handler(commands=['register'])
def handle_register(message, bot):
    bot.set_state(message.from_user.id, RegisterState.first_name, message.chat.id)
    bot.send_message(message.chat.id, 'Type your first name:')

    
@bot.message_handler(state=RegisterState.first_name)
def handle_first_name(message, bot):
    first_name = message.text
    # do something with first_name
    
    bot.set_state(message.from_user.id, RegisterState.last_name, message.chat.id)
    bot.send_message(message.chat.id, 'Type your last name:')


@bot.message_handler(state=RegisterState.last_name)
def handle_last_name(message, bot):
    last_name = message.text
    # do something with last_name

    bot.delete_state(message.from_user.id, message.chat.id)

Что произошло в сниппете выше:

  1. Мы создали новые состояния и сложили их в класс RegisterState.

  2. Завели обработчик для команды /register так же как и раньше, но внутри обработчика проставили пользователю состояние RegisterState.first_name.

  3. Завели обработчик handle_first_name, который вызывается только для пользователей в состоянии RegisterState.first_name. В нём переназначили состояние на RegisterState.last_name.

  4. Завели обработчик handle_last_name, который вызывается только для пользователей в состоянии RegisterState.last_name. В нём очистили состояние пользователя с помощью bot.delete_state.

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

Здесь я собрала все части кода в один сниппет для наглядности, однако в репозитории-шаблоне этот же код разбит по разным файлам.

  • Все состояния определены в файле bot/states.py.

  • Все обработчики определены в файле bot/handlers.py.

  • Структура бота с правилами фильтрации собрана в bot/structure.py.

Обмен дополнительной информацией между обработчиками

Бонусом использования стейтов библиотеки TeleBot является возможность сохранять дополнительную информацию о пользователе для использования другим обработчиком. Для этого можно воспользоваться контекст-менеджером:

with bot.retrieve_data(user_id, chat_id) as data:
    data["field"] = "some value" # set value
    some_variable = data["field"] # read value of "field" into variable some_variable

В сценарии регистрации воспользуемся этой возможностью, чтобы в обработчике handle_last_name получить одновременно и значение фамилии, и имени:

from telebot.handler_backends import State, StatesGroup


class RegisterState(StatesGroup):
    first_name = State() # state for choosing first name
    last_name = State() # state for choosing last name
    

@bot.message_handler(commands=['register'])
def handle_register(message, bot):
    bot.set_state(message.from_user.id, RegisterState.first_name, message.chat.id)
    bot.send_message(message.chat.id, 'Type your first name:')

    
@bot.message_handler(state=RegisterState.first_name)
def handle_first_name(message, bot):
    first_name = message.text
    with bot.retrieve_data(user_id, chat_id) as data:
        data['first_name'] = first_name # save first_name for later
    
    bot.set_state(message.from_user.id, RegisterState.last_name, message.chat.id)
    bot.send_message(message.chat.id, 'Type your last name:')


@bot.message_handler(state=RegisterState.last_name)
def handle_last_name(message, bot):
    with bot.retrieve_data(user_id, chat_id) as data:
        first_name = data['first_name'] # get first_name from previous step
    last_name = message.text
    
    # do something with last_name and first_name

    bot.delete_state(message.from_user.id, message.chat.id)

А что всё-таки надо для этого сделать?

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

В библиотеке TeleBot реализованы несколько видов хранилищ:

  • StateMemoryStorage - для хранения стейтов локально - в атрибуте инстанса хранилища.

  • StatePickleStorage - для сохранения стейтов на диск с помощью pickle.

  • StateRedisStorage - для хранения стейтов в базе данных Redis.

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

Хранилище для YDB реализовано в файле bot/states.py - класс StateYDBStorage. Основой хранилища является простая таблица с двумя колонками user_id и state.

Схема таблицы для хранилища
CREATE TABLE `states`
(
  `user_id` Uint64,
  `state` Utf8,
  PRIMARY KEY (`user_id`)
);

Для инициализации используется подключение к YDB pool и всего три функции для взаимодействия с базой, реализованные в database/model.py:

  • get_state(pool: SessionPool, user_id: int) -> dict, возвращающая словарь со стейтом и дополнительной информацией

  • set_state(pool: SessionPool, user_id: int, full_state: dict) -> None для записи стейта и дополнительной информации в базу

  • clear_state(pool: SessionPool, user_id: int) -> None для очистки стейта и дополнительной информации

Об этих функциях и поговорим дальше.

Работа с базой данных - удобно и безопасно

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

Рассмотрим сначала как корректно делать запросы в базу из любого python-кода, а затем как делать запросы из обработчиков.

Инициализация подключения к базе

Инициализация подключения к базе реализована в файле database/ydb_settings.py. Работа с базой YDB происходит с помощью пула pool, который создаётся в файле index.py и дальше передаётся в функцию создания бота bot = create_bot(BOT_TOKEN, pool).

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

Подготовленные запросы или prepared statements

Все SQL запросы, которые использует бот, собраны в файле database/queries.py. Для YDB используется диалект SQL под названием YQL (документация).

Можно заметить, что все запросы в файле имеют похожую структуру:

get_user_state = f"""
    DECLARE $user_id AS Uint64;

    SELECT state
    FROM `{STATES_TABLE_PATH}`
    WHERE user_id == $user_id;
"""

Сначала объявляются параметры запроса с помощью ключевого слова DECLARE, названия и типа, а затем параметры используются в теле запроса ниже.

В файле database/model.py определены python-функции для выполнения запросов. Они являются проводниками между python-кодом и SQL запросами и написаны так, чтобы "внешнему" коду ничего не нужно было знать о структуре базы, самих запросах и возможных результатах SELECT, только предоставить пул, требуемые параметры и получить ответ. Например, так выглядит функция, исполняющая запрос get_user_state, приведённый выше:

def get_state(pool, user_id):
    results = execute_select_query(pool, queries.get_user_state, user_id=user_id)
    if len(results) == 0:
        return None
    if results[0]["state"] is None:
        return None
    return json.loads(results[0]["state"])

Самая важная часть взаимодействия пользователя, кода и базы заложена в функцию execute_select_query (и аналогичную execute_update_query) из файла database/utils.py.

import ydb


def _format_kwargs(kwargs):
    return {"${}".format(key): value for key, value in kwargs.items()}


def execute_select_query(pool, query, **kwargs):
    def callee(session):
        prepared_query = session.prepare(query)
        result_sets = session.transaction(ydb.SerializableReadWrite()).execute(
            prepared_query, _format_kwargs(kwargs), commit_tx=True
        )
        return result_sets[0].rows

    return pool.retry_operation_sync(callee)

В них реализованы так называемые "подготовленные запросы" или "prepared queries" / "prepared statements". YDB-версия подготовленных запросов описана в документации.

Что происходит:

  • Текст запроса "подготавливается" к подставлению аргументов в session.prepare.

  • Полученные на вход python-аргументы форматируются функцией _format_kwargs.

  • И подставляются в подготовленный запрос с помощью session.transaction(..).execute(..), запрос выполняется.

  • Дополнительно с помощью конструкции pool.retry_operation_sync(callee) запрос выполняется несколько раз, если в процессе возникают проблемы.

Но зачем?

  • Бот работает в том числе с данными, введёнными пользователями в свободной форме, что делает код уязвимым к SQL-инъекциям, то есть попыткам ввести такие данные, которые сломают ожидаемое поведение запроса и выдадут наружу лишнюю информацию из базы. Подготовка запроса и подставление аргументов описанным выше образом защищает от таких атак: аргументы автоматически экранируются, не позволяя изменить запрос.

  • Использование подготовленных запросов улучшает производительность, сокращая оверхед на компиляцию запросов, отличающихся только значением параметров.

Мой код использует подготовленные запросы специфичные для YDB, однако для других баз данных они тоже существуют и должны использоваться.

Как подружить бота и базу данных

Обращаться к базе требуется не только для получения состояний пользователей, но и для получения другой информации внутри обработчиков сообщений. Для этого нужно передать информацию о подключении к базе (пул) в обработчик.

Аргументы обработчика подставляются в него с помощью метода или декоратора регистрации bot.register_message_handler. По умолчанию в callbackфункцию передаётся message: telebot.types.Message, а если в bot.register_message_handler передать параметр pass_bot=True, то ещё и сам инстанс бота. Нативных способов передать посторонний аргумент нет.

Чтобы решить эту проблему, в шаблоне используется functools.partial. Эта функция частично предзаполняет аргументы callbackфункции аргументами, перечисленными в качестве его собственных, и возвращает callable объект. При вызове объекта аргументы вызова (у нас - message и bot) добавляются к предзаполненным. Таким образом можно передать в обработчик любую дополнительную информацию, в нашем случае - соединение с базой.

Код регистрации теперь выглядит так:

from functools import partial

from telebot import TeleBot

from database.ydb_settings import get_ydb_pool


pool = get_ydb_pool(...) # create db connection
bot = TeleBot(...) # initialize bot


def handler_function(message, bot, pool):
    pass # do something


bot.register_message_handler(
    partial(handler_function, pool=pool),
    commands=[...],
    state=[...],
    pass_bot=True
)

Теперь мы готовы к регистрации последовательностей обработчиков. Когда логика работы бота довольно сложная, важно не запутаться в последовательности определения команд и дереве логики, поэтому для удобства задание обработчиков разделено на сьюты, определённые в файле bot/structure.py, - списки объектов простого класса Handler: каждый начинается с какой-то команды и продолжается возможными сюжетами с помощью фильтров по стейтам и командам.

Вот пример структуры бота со всего одним сьютом "удалить аккаунт" из bot/structure.py:

from functools import partial

from telebot import TeleBot, custom_filters

from bot import handlers as handlers
from bot import states as bot_states


class Handler:
    def __init__(self, callback, **kwargs):
        self.callback = callback
        self.kwargs = kwargs


def get_delete_account_handlers():
    return [
        Handler(callback=handlers.handle_delete_account, commands=["delete_account"]),
        Handler(
            callback=handlers.handle_finish_delete_account,
            state=bot_states.DeleteAccountState.are_you_sure,
        ),
    ]

# ... define other handler suites ...

def create_bot(bot_token, pool):
    state_storage = bot_states.StateYDBStorage(pool)
    bot = TeleBot(bot_token, state_storage=state_storage)

    handlers = []
    handlers.extend(get_delete_account_handlers())
    # add other suites

    for handler in handlers:
        bot.register_message_handler(
            partial(handler.callback, pool=pool), **handler.kwargs, pass_bot=True
        )

    bot.add_custom_filter(custom_filters.StateFilter(bot))
    return bot

Логирование для бота

Мы прошлись почти по всему коду шаблона, осталось только обсудить логирование, которое реализовано в файле logs.py.

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

Напоминание - логи выглядят так

Делается это совсем несложно, но хочется обратить внимание на два вопроса:

  • Как логировать события в формате, который удобно просматривать в Яндекс.Облаке?

  • Как удобно логировать выполнение всех обработчиков, не дублируя код?

Формат логов для функции Яндекс.Облака

Функции Яндекс.Облака требуют структурированного логирования для того, чтобы логи правильно парсились и отображались. Дефолтные настройки, конечно, тоже допустимы, но работа с такими логами будет немного затруднена.

Какие преимущества у структурированных логов в функции:

  • Корректная работа фильтрации по уровню логов с помощью селектора в интерфейсе.

  • Возможность записи и отображения дополнительной информации в интерфейсе по клику на кнопку > слева от лога.

Как настроить логирование написано в документации функций. Эти настройки используются и в шаблоне:

import logging

from pythonjsonlogger import jsonlogger


class YcLoggingFormatter(jsonlogger.JsonFormatter):
    def add_fields(self, log_record, record, message_dict):
        super(YcLoggingFormatter, self).add_fields(log_record, record, message_dict)
        log_record["logger"] = record.name
        log_record["level"] = str.replace(
            str.replace(record.levelname, "WARNING", "WARN"), "CRITICAL", "FATAL"
        )


logHandler = logging.StreamHandler()
logHandler.setFormatter(YcLoggingFormatter("%(message)s %(level)s %(logger)s"))

logger = logging.getLogger("logger")
logger.addHandler(logHandler)
logger.setLevel(logging.DEBUG)

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

Например, в шаблоне залогированы пойманные исключения. В текст лога выводится id пользователя и ошибка, а traceback добавлен в extra параметр:

logger.error(
    f"[LOG] Failed {func.__name__} - chat_id {chat_id} - exception {e}",
    extra={
        "text": text,
        "arg": str(args),
        "kwarg": str(kwargs),
        "error": e,
        "traceback": traceback.format_exc(),
    },
)
Скриншот - короткий лог ошибки

Скриншот - лог ошибки с подробностями

Логирование обработчиков

Во время разработки и для расследования ошибок удобно логировать выполнение всех обработчиков, а именно chat_id или user_id, которые общаются с ботом, текст сообщения и то, какой обработчик был вызван.

В файле logs.py создан декоратор logged_execution, который ищет в аргументах обработчика id чата и логирует начало и конец выполнения с названием обработчика, текстом сообщения и информацией о пользователе. Если во время выполнения обработчика произошла ошибка, она тоже логируется с трейсбеком.

Все обработчики в файле bot/handler.py обёрнуты декоратором, например, handle_start:

@logged_execution
def handle_start(message, bot, pool):
    bot.send_message(message.chat.id, texts.START, reply_markup=keyboards.EMPTY)

Примеры того, как выглядят логи, можно увидеть под спойлерами выше.

Разумеется, может понадобиться залогировать дополнительные события внутри обработчиков и в реализации хранилища, тогда в соответствующем файле нужно импортировать from logs import logger и использовать как того требует ситуация, например, logger.info("Hello, world!").

Ещё раз коротко

В этой статье мы подробно прошлись по коду создания диалогового Телеграм бота из репозитория ydb_serverless_telegram_bot. В процессе мы:

  • Разобрали создание и использование пользовательских стейтов, реализованных в библиотеке TeleBot, убедились в преимуществах этого подхода.

  • Обсудили, как настроить безопасную работу с базой данных.

  • Увидели, как можно прокидывать в обработчики команд произвольные аргументы, в нашем случае - соединение с базой данных.

  • Создали подробное логирование и форматировали его для отображения в функции Яндекс.Облака.

  • Проговорили, в каком файле шаблона что реализовано, чтобы проще было ориентироваться и добавлять свой код.

Что дальше?

В следующей статье покажу, как организовать end-to-end тестирование бота с помощью pytest. Kiitos lukemisesta ja nähdään!