Контекст диалога в pytelegrambotapi

в 16:15, , рубрики: python, telebot, telegram
Контекст диалога в pytelegrambotapi - 1

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

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

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

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

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

states = {user_id: "name"}

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

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

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

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

Метод в лоб

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

классно, да? Вроде работает, но что если нам нужно чуть больше чем имя, возраст например. Пока логично, просто изменяем 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()
Контекст диалога в pytelegrambotapi - 2

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

Мой вариант

Потратив два дня не очень активной мыслительной деятельности я сообразил библиотеку 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 имеет всё, что нужно при работе со сценариями, но очень громоздкий, из-за чего писать с ним маленькие приложения становится очень сложно, каждый имеет место быть, а вам желаю писать поменьше костылей и не писать как я. Спасибо за прочтение.

Автор: tiver

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js