Контекст в чат-ботах играет ключевую роль в создании удобных и интерактивных взаимодействий с пользователем. Без него бот теряет связь с предыдущими сообщениями, что усложняет диалог. В этой статье мы рассмотрим, как реализовать систему контекстных диалогов на Python с использованием библиотеки telebot. Мы покажем, как управлять состоянием диалога, сохранять контекст и обрабатывать несколько пользователей одновременно, делая бота более умным и персонализированным.

Зачем нужен контекст диалога?

Я думаю, вы часто, пользуясь телеграмм-ботами, вводите какую-то кастомную информацию, например цену товара, при этом не задумываясь, как сервис понимает, что ваше сообщение — это именно цена, а не имя или описание, очевидно, по предыдущему сообщению, где бот спрашивает о цене, спросил — ответили. Это и называют контекстом разговора. Другой пример — любой анонимный чат, боту нужно помнить, с каким пользователем вы говорите, он это знает, ведь он вас с ним и связал. Таким образом, без контекста диалога вам придётся каждый раз сообщать, что это такое, а далеко не все хотят разбираться, на кой чёрт вам 100500 команд с названием /add_name, /add_price, /add_description, и рано или поздно все запутаются.

Способы реализации

Я буду рассматривать именно библиотеку telebot, хотя в других, более продвинутых, есть крутые встроенные инструменты, например в python-telegram-bot, но в них чёрт ногу сломит, поскольку я немного ленивый низкоквалифицированный, я остановлюсь на pytelegrambotapi.
Первое, что придёт в голову большинству, это создать словарь для всех текущих диалогов, что-то по типу:

states = {user_id: "name"}

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

Далее за ответом я полез к универсальному хранителю знаний — Чату ГПТ, который уже посоветовал более релевантное решение, встроенное в самого telebot'а: стейты, это уже более продвинутое решение, которое подходит для получения пользовательского ввода, но всё же не лишено минусов, например контекст всё ещё отсутствует, это просто стейты, хоть теперь мы и можем придерживаться технологии вопрос-ответ, но диалога выстроить нормально не получится без создания сторонних словарей.

3-й способ был собран мной из подручных материалов на коленке за 2 дня, аналогов я не нашёл, и поэтому заявляю, что это мой гриб и я его ем. Это библиотека telebot-dialogues, она позволяет создавать диалоги для пользователей с сохранением контекстных переменных, истории сообщений и приостановкой диалога.

Также хочу обратить внимание что message.from_user.id НЕ эквивалентно call.message.from_user.id, на деле message.from_user.id = call.message.chat.id

Теперь рассмотрим каждый способ подробней.

Метод в лоб

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

классно, да? Вроде работает, но что если нам нужно чуть больше чем имя, возраст например. Пока логично, просто изменяем take_name и главный хендлер:

@bot.message_handler(content_types=["text"])
def handle_text(message):
    state = states.get(message.from_user.id)
    match state:
        case 'name':
            take_name(message)
        case 'age':
            take_age(message)
            
def take_name(message):
    bot.send_message(message.chat.id, f'твоё имя: {message.text}! Введи свой возраст')
    states[message.from_user.id] = 'age'

и соответственно добавляем:

def take_age(message):
    bot.send_message(message.chat.id, f'твой возраст: {message.text} лет!')
    states[message.from_user.id] = None

А сохранять эти данные как? Вроде логично прикурить сохранение в файл или БД, но что, если сохранить нужно всё разом, тогда придётся использовать ещё один костыль: временное хранилище.

temp_info_save = {}

и соответственно изменяем всё под новые суровые реалии:

def take_name(message):
    bot.send_message(message.chat.id, f'твоё имя: {message.text}!')
    temp_info_save[message.from_user.id] = {'name': message.text}
    states[message.from_user.id] = 'age'

def take_age(message):
    bot.send_message(message.chat.id, f'твой возраст: {message.text} лет!')
    temp_info_save[message.from_user.id]['age'] = message.text
    states[message.from_user.id] = None

Конечно, можно добавить и сохранение истории сообщений, и состояния и т. д., но я думаю, вы скорее головой тронетесь, чем напишите это в более-менее адекватном виде, так что этот способ можно хоронить.

Окончательный код:

from telebot import TeleBot

bot = TeleBot('YOUR_BOT_TOKEN')

states = {}
temp_info_save = {}

@bot.message_handler(commands=['start'])
def start_command(message):
    states[message.user_from.id] = 'name'
    bot.send_message(message.chat.id, 'Привет! Введи своё имя.')

@bot.message_handler(content_types=["text"])
def handle_text(message):
    state = states.get(message.from_user.id)
    match state:
        case 'name':
            take_name(message)
            bot.send_message(message.chat.id, f'Привет, {message.text}! Введи свой возраст.')

        case 'age':
            take_age(message)
            bot.send_message(message.chat.id, 'Теперь ты можешь отправлять мне сообщения.')


def take_name(message):
    bot.send_message(message.chat.id, f'твоё имя: {message.text}!')
    temp_info_save[message.from_user.id] = {'name': message.text}
    states[message.from_user.id] = 'age'

def take_age(message):
    bot.send_message(message.chat.id, f'твой возраст: {message.text} лет!')
    temp_info_save[message.from_user.id]['age'] = message.text
    states[message.from_user.id] = None


if __name__ == '__main__':
    bot.polling()

Уже имеющийся контроллер состояний

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

import telebot
from telebot import types
from telebot.handler_backends import State, StatesGroup

bot = telebot.TeleBot('YOUR_BOT_TOKEN')

class UserState(StatesGroup):
    waiting_for_name = State()  # Состояние ожидания имени

@bot.message_handler(commands=['start'])
def start_command(message):
    bot.set_state(message.chat.id, UserState.waiting_for_name)
    bot.send_message(message.chat.id, 'Привет! Введи своё имя.')

@bot.message_handler(content_types=["text"], state=UserState.waiting_for_name)
def take_name(message):
    user_name = message.text
    bot.send_message(message.chat.id, f'Твоё имя: {user_name}!')
    
    bot.delete_state(message.chat.id)

# Запуск бота
if __name__ == '__main__':
    bot.polling()

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

import telebot
from telebot import types
from telebot.handler_backends import State, StatesGroup

bot = telebot.TeleBot('YOUR_BOT_TOKEN')

temp_info_save = {}

class UserState(StatesGroup):
    waiting_for_name = State()  # Состояние ожидания имени
    waiting_for_age = State()   # Состояние ожидания возраста

@bot.message_handler(commands=['start'])
def start_command(message):
    # Устанавливаем состояние ожидания имени
    bot.set_state(message.chat.id, UserState.waiting_for_name)
    bot.send_message(message.chat.id, 'Привет! Введи своё имя.')

@bot.message_handler(content_types=["text"], state=UserState.waiting_for_name)
def take_name(message):
    user_name = message.text
    bot.send_message(message.chat.id, f'Твоё имя: {user_name}!')
    
    temp_info_save[message.chat.id] = {'name': user_name}
    
    bot.set_state(message.chat.id, UserState.waiting_for_age)
    bot.send_message(message.chat.id, f'Привет, {user_name}! Введи свой возраст.')

@bot.message_handler(content_types=["text"], state=UserState.waiting_for_age)
def take_age(message):
    user_age = message.text
    bot.send_message(message.chat.id, f'Твой возраст: {user_age} лет!')
    
    temp_info_save[message.chat.id]['age'] = user_age
    
    bot.delete_state(message.chat.id)
    
    bot.send_message(message.chat.id, 'Теперь ты можешь отправлять мне сообщения.')

if __name__ == '__main__':
    bot.polling()

Да, это ровно то же самое, что мы делали в предыдущем способе, только по-умному, подводя итог: способ сильно лучше, но по сути является тем же самым.

Мой вариант

Потратив два дня не очень активной мыслительной деятельности я сообразил библиотеку telebot-dialoguesустановка как обычно:

pip install telebot-dialogue

И да, это такой же словарь, но с красивой обёрткой, но моя обёртка — приемлемый интерфейс для взаимодействия, всё строится на двух слонах и одном слонёнке: Dialogue, DialogueManager и DialogueUpdater, первый, как следует из названия, является объектом диалога, второй — менеджером, а третий — это подкласс менеджера, чтобы не делать кучу функций под обновление переменных, вы просто редактируете информацию в контекстом менеджера.

from telebot import TeleBot
from telebot_dialogue import DialogueManager, Dialogue

bot = TeleBot("Your_BOT_TOKEN")
dialogue_manager = DialogueManager()

@bot.message_handler(commands=['start'])
def start_command(message):
    user_id = message.from_user.id
    dialogue = Dialogue(user_id, take_name, end_func=end_dialogue)
    dialogue_manager.add_dialogue(dialogue) 
    # если диалог с этим пользователем уже есть то новый не начнётся, force=True заменяет диалог
    bot.send_message(message.chat.id, 'Привет! напиши своё имя:')

def end_dialogue(dialogue):
    bot.send_message(dialogue.user_id,
                     f'Завершение диалога, вот твоя инфа имя: {dialogue.get_context('name')}, '
                     f'возраст: {dialogue.get_context('age')}')


def take_age(message, dialogue):
    age = int(message.text)
    with dialogue_manager.update(dialogue.user_id) as update_dialogue:
        update_dialogue.update_context('age', age)
        bot.send_message(message.chat.id, 'Спасибо! Тебе {} лет.'.format(age))
        context = update_dialogue.get_context('name')

        print(context) # типа сохранение в дб
        update_dialogue.delete_dialogue()


def take_name(message, dialogue):
    name = message.text
    with dialogue_manager.update(dialogue.user_id) as update_dialogue:
        update_dialogue.update_context('name', name)
        bot.send_message(message.chat.id, 'Доброго утра, {}! Сколько тебе лет?'.format(name))
        update_dialogue.handler = take_age

@bot.message_handler(content_types=['text'])
def handle_text(message):
    if not dialogue_manager.handle_message(message): # Если диалога нет, то отправляем приветствие
        bot.send_message(message.chat.id, 'Привет! Это бот для общения с пользователем. напиши /start, чтобы начать.')


if __name__ == '__main__':
    bot.polling()

Громоздко? Очень, зато потенциал куда больше, чем у предыдущих способов, это может быть использовано при публикации объявления, когда нужно заполнить форму, и хранить всё отдельно неудобно. А теперь можно перейти к более сложным функциям этой библиотеки:

История

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

def end_dialogue(dialogue):
    bot.send_message(dialogue.user_id,
                     f'Завершение диалога, вот твоя инфа имя: {dialogue.get_context('name')}, '
                     f'возраст: {dialogue.get_context('age')} твоё первое сообщение - {dialogue.history[0].text}.')

Статус

например если в анонимном чате пользователь решит приостановить общение, то вызывается dialogue.stop_dialogue(), вызывающая функцию pause_func, dialogue.continue_dialogue() делает ровно противоположное, вызывает функцию continue_func, dialogue.delete_dialogue() вызывает end_func и завершает диалог. Функции pause_func, continue_func и end_func передаются при создании диалога. При приостановке и возобновлении меняется статус. Хендлер работает только с state=true

Контекст и сброс

update_context(key, value) добавляется в контекст данные в формате ключ значение,
get_context(key, default=None) получает данные из контекста по ключу
clear_context и clear_history делают ровно то, что вы от них ожидаете
reset_dialogue сбрасывает диалог до заводских(уничтожает историю и контекст)

Менеджер

continue_dialogue, finish_dialogue и stop_dialogue являются оболочками и просто вызывают соответствующие функции у диалога по user_id

Контекстный updater

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

with manager.update(user_id) as dialogue:
  # любые действия с дилогом
  dialogue.handler = new_handler

print(manager.find_dialogue(user_id).handler) # new_handler

Пример анонимного чата со всеми функциями

from telebot import TeleBot
from telebot_dialogue import DialogueManager, Dialogue

bot = TeleBot("YOUR_BOT_TOKEN")
dialogue_manager = DialogueManager()


@bot.message_handler(commands=['start'])
def find_conversation(message):
    user_id = message.from_user.id
    partner = '123' # поиск собеседника
    if not dialogue_manager.find_dialogue(user_id):
        dialogue = Dialogue(user_id, conversate, end_func=end_dialogue, context={'partner': partner})
        dialogue_manager.add_dialogue(dialogue)
        bot.send_message(message.chat.id, 'Привет! мы нашли тебе собеседника, можешь с ним поговорить:')
        
    with dialogue_manager.update(user_id) as update_dialogue:
        update_dialogue.clear_context()
        update_dialogue.update_context('partner', partner)


def end_dialogue(dialogue):
    bot.send_message(dialogue.user_id, 'Диалог завершен.')
    bot.send_message(dialogue.get_context('partner'), 'Твой собеседник завершил диалог.')


def conversate(message, dialogue):
    bot.send_message(dialogue.get_context('partner'), message.text)


@bot.message_handler(commands=['pause'])
def pause_dialogue_handler(message):
    user_id = message.from_user.id
    if dialogue_manager.stop_dialogue(user_id):
        return

    bot.send_message(message.chat.id, 'у тебя нет активного диалога. Напиши /start, чтобы начать.')

@bot.message_handler(commands=['continue'])
def continue_dialogue_handler(message):
    user_id = message.from_user.id
    if dialogue_manager.continue_dialogue(user_id):
        return

    bot.send_message(message.chat.id, 'у тебя нет активного диалога. Напиши /start, чтобы начать.')

@bot.message_handler(content_types=['text'])
def handle_text(message):
    if not dialogue_manager.handle_message(message): # Если диалога нет, то отправляем приветствие
        bot.send_message(message.chat.id, 'У тебя сейчас нет диалога. Напиши /start, чтобы начать.')


def pause_dialogue(dialogue):
    bot.send_message(dialogue.user_id, 'Диалог приостановлен.')
    bot.send_message(dialogue.get_context('partner'), 'Твой собеседник приостановил диалог.')


def continue_dialogue(dialogue):
    bot.send_message(dialogue.user_id, 'Диалог продолжен.')
    bot.send_message(dialogue.get_context('partner'), 'Твой собеседник продолжил диалог.')


if __name__ == '__main__':
    bot.polling()

Вывод

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

Комментарии (10)


  1. slair
    01.01.2025 16:40

    где ключи — это айдишники юзеров, а ключи — их состояния,

    хотелось бы ясности


    1. tiver Автор
      01.01.2025 16:40

      словарь states = {user_id: "state"}, user_id: int, а стейт - строка, и далее в хендлере сверять с каждым возможным и вызываю соответствующую функцию, например if states[message.from_user.id] == "test": test(message), также можно создавать пустые класс как состояния, но тогда как по мне легче второй способ использовать.


      1. starwalkn
        01.01.2025 16:40

        Возможно, комментарий был по поводу ключей. Ключи - идентификаторы, и снова ключи - стейты. Перепутали со значением вместо ключа.


  1. gfiopl8
    01.01.2025 16:40

    А есть что-нибудь для телебота для меню выбора из большого списка или дерева?


    1. tiver Автор
      01.01.2025 16:40

      Не совсем понял, для выбора из большого списка можно использовать инлайн клавиатуру, или если он очень большой обозначаешь каждое значение числом, вот так например
      1) выбор 1

      2) выбор 2
      и дальше запрашиваешь у пользователя число.
      А что вы подразумеваете под деревом я не понял, можете уточнить?


      1. gfiopl8
        01.01.2025 16:40

        Большой список это когда листать приходится, вправо влево, или может как то получше, по 10 страниц вперед/назад не знаю. Может такое лучше как то через @ делать.

        Дерево - то же самое но не список а дерево Ж)


        1. gfiopl8
          01.01.2025 16:40

          Ну например надо выбрать 1 язык из 100.

          Текстовое сообщение Выберите язык, и к нему прикреплена клавиатура с кнопками на которых названия языков написаны.

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

          Такой себе вариант но хотя бы такой бы найти, готовый.

          Есть еще "инлайн" боты, это когда в ты в строке сообщения начинаешь писать @имя_бота и дальше запрос. Типа @gifмем с котятами, и у тебя вылезает особое окно с результатами, возможно такой вариант можно будет адаптировать под выбор из большого списка.


          1. tiver Автор
            01.01.2025 16:40

            Вот что-то подобное для списка:

            import telebot
            
            bot = telebot.TeleBot('YOUT_TOKEN')
            
            # список языков
            languages = ['русский', 'английский', 'немецкий', 'очень много языков', 'другие языки', 'ещё языки']
            # я поставлю два на страницу, мне лень много перечеслять
            
            @bot.message_handler(commands=['start'])
            def start_command(message):
                keyboard = get_keyboard(0, languages, 2)  # первая страница с двумя языками
                # меню где нужно выбрать
                bot.send_message(message.chat.id, 'привет, выбери язык:', reply_markup=keyboard)
            
            
            def get_keyboard(index, list_of_languages, languages_in_page):
                languages_on_page = languages[index:index+languages_in_page]
                print(languages_on_page)
                print(index)
                keyboard = telebot.types.InlineKeyboardMarkup(row_width=2) # замени взависимости от размера текста
                for language in languages_on_page:
                    print(language)
                    keyboard.add(telebot.types.InlineKeyboardButton(text=language, callback_data=language+"_"+"language_choose"))
            
                row = []
                if index > 0:
                    row.append(telebot.types.InlineKeyboardButton(text='назад', callback_data='prev'+"_"+str(index-languages_in_page)))
            
                if len(list_of_languages) > index + languages_in_page:
                    row.append(telebot.types.InlineKeyboardButton(text='далее', callback_data='next'+"_"+str(index+languages_in_page)))
            
                keyboard.row(*row)
                return keyboard
            
            @bot.callback_query_handler(func=lambda call: 'next' in call.data)
            def next_page(call: telebot.types.CallbackQuery) -> None:
                bot.edit_message_reply_markup(call.message.chat.id, call.message.message_id, reply_markup=get_keyboard(int(call.data.split('_')[1]), languages, 2))
            
            @bot.callback_query_handler(func=lambda call: 'prev' in call.data)
            def next_page(call: telebot.types.CallbackQuery) -> None:
                bot.edit_message_reply_markup(call.message.chat.id, call.message.message_id, reply_markup=get_keyboard(int(call.data.split('_')[1]), languages, 2))
            
            
            @bot.callback_query_handler(func=lambda call: 'language_choose' in call.data)
            def language_choose(call: telebot.types.CallbackQuery) -> None:
                bot.send_message(call.message.chat.id, f'Вы выбрали язык: {call.data.split("_")[0]}')
            
                
            bot.polling()

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

            насчёт инлайн ботов не уверен, я никогда не сталкивался и вообще не уверен что такое возможно в telebot.


  1. RioTwWks
    01.01.2025 16:40

    А в чём проблема использовать aiogram?


    1. tiver Автор
      01.01.2025 16:40

      Как по мне он значительно сложнее чем pytelegrambotapi, и далеко не все хотят в нём разбираться, мне кажется что в простых ботах нет необходимости в aiogram.