Мои первые шаги

Создано специально для телеграм канала https://t.me/debug_u

Как я учусь делать телеграм бота?

Вступление

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

В чем идея бота?

Перед тем, как выбрать идею для бота, я понял, что лучше не пытаться сразу сделать "сложного" бота со сверхбогатым функционалом, потому что сложность на начальном этапе, как правило, всегда убивает энтузиазм и желание заниматься выбранным делом в принципе. Также я хотел сделать что-то более менее пригодное и актуальное, что позволило бы мне освоить весь базовый функционал, необходимый для реальных проектов. Скажем, можно было сделать криптобота, который бы позволял получать цену в $ интересующей крипты. И на самом деле я такой сделал - это слишком просто. Уверенности я в себе не почувствовал уж точно. Такой бот в чистом виде неактуален, хотя врать не буду, я поработал с API коинмаркеткапа, что уже неплохо.

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

В общем решено - делаю бота для онлайн заказа в кофейне

На чем я буду делать бота?

Разумеется, это никакой не конструктор. Я уже писал у себя на канале, почему лучше изучить прогу и написать бота, чем пользоваться конструкторами. Если коротко, то

Конструктор обычно решает шаблонные задачи, нетипичные проблемы всегда требует незаурядного ума.

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

  • PHP

  • Java

  • nodejs

  • Python

  • C#

  • Go

  • еще парочка других

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

Какая библиотека лучше? Разумеется, лично я сказать не могу, так как не имел опыта работы с ними. С другими тоже особо не говорил по этому поводу. Если судить по звездам на гитхабе, то python-telegram-bot обходит остальные. Потом идет Telepot и замыкает тройку pyTelegramBotAPI. Тем не менее я пользуюсь последней. Во многих обучающих примерах использовали именно ее, плюс она показалась довольно простой и привлекательной.

Осталось упомянуть базу данных и используемую среду разработки. Я сразу понял, что без БД никуда, ну точнее можно без нее реализовать бота, но в реальных проектах вряд ли боты обходятся без БД, поэтому я решил ее использовать. MySQL/PostgreSQL я не знаю (за что даже стыдно), был опыт работы с SQLite, правда не в чистом виде, а когда изучал фреймворк Django (об этом как-нибудь потом), но там используется ORM. Если не знаете что такое ORM, могу порекомендовать быстро прочесть тут, мне показалось довольно хорошим такое объяснение.... Возвращаясь к БД, я решил использовать MongoDB. У меня был небольшой опыт работы с ней, когда изучал Node.js, она мне сразу понравилась, и мне показалось, что она вполне годится для моего бота.

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

Хотя я бы новичкам рекомендовал PyCharm - не ошибетесь.

Первые шаги

Разумеется, изначально я посмотрел документацию выбранной библиотеки - это надо стараться делать первым делом, когда беретесь изучать что-то новое. Но и это правило имеет исключения. По моему мнению хорошая документацию должна содержать не только список имеющихся в ней "вещей", а непосредственно примеры их использования. Когда автор описывает способы реализации своих функций и дает к ним пояснения, он показывает свое видение. Тебе, как ученику, становится легче проследить его логику и понять надобность. Я читал документацию по Django и мне она понравилась. На тебя не валят сразу гору сухой информации, напротив, новичкам предлагают создать просто веб-приложение, где поясняется смысл почти каждой команды. Ты поэтапно собираешь пазлы, получая в конце всю картину. А уже после тебе дают более углубленные знания и эффективные приемы.

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

КОГДА УЖЕ ПРИСТУПИМ К СОЗДАНИЮ БОТА???

Можно выдохнуть, уже! Условимся, что у вас установлена понравившаяся вам среда разработки, есть python. Причем python3. Версию можете ставить самую последнюю, на момент написания это python 3.7.

Теперь надо создать бота. Для этого в тг существую "крестный отец" - с ником @BotFather. Жмете /start, потом /newbot , вводим имя нашего будущего бота, вводим его ник типа @something_bot - в общем следуем инструкции. В случае успеха он пришлет нам token бота типа Use this token to access the HTTP API: 431603025:AAGH3fmgfX6C_AuoiynFuMUgfsd9oPDzSo

Именно с токеном мы и будем работать.

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

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

В одном из чатов добрые люди просто скинули ссылку https://www.freeopenvpn.org/connect.php и сказали следовать инструкции (она на сайте подробно описана, также я для своих подписчиков на всякий случай описал свою инструкцию).

  1. Скачать прогу в зависимости от вашей ОС

  2. Далее выбираем понравившийся сервер

  3. Качаем файл с настройками.

  4. Вводим логин freeopenvpn и пароль, которые меняется раза два в сутки вроде

Скорость сильно не падает, зато теперь я смог запускать бота, и он работал.

Кстати, я не уточнил, сам я использую macOS, винду не очень люблю и вам не советую. Ставьте linux. Либо второй системой рядом с виндой, либо на виртуальной машине. Расписывать про выбор дистрибутива и его установку смысла не вижу, по крайней мере тут. Если будет желание и необходимость, отдельно сделаю. Сам я с линуксом мало работал, плюс начинал знакомство сразу с довольно хардкорного фреймфорка - Arch Linux.

Начинаем кодить

Когда я изучал веб-программирование, в одном уроке хороший человек сказал, что в проектах надо использовать виртуальное окружение. Потому что у каждой библиотеки есть свои версии, иногда новые версии несовместимы со старыми, отсюда могут возникать ошибки. Банальный пример с самим питоном: у вас несколько проектов, один из которых на втором питоне, другой на третьем. А эти версии без обратной совместимости! Помимо этого вы очень трудолюбивый и имеет еще три проекта на Django, где у одного версия 1.10, у второго 2.0 и третьего 1.8.

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

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

Напомню, я пишу на mocOS, но команды также будут актуальны (не все) и для Linux. Оконщикам придется перебираться на другую систему (но вам будет полезно, обещаю).

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

pip3 install virtualenv

pip3 - это менеджер пакетов для python, при чем для python 3 будет pip3, для python 2 просто pip. virtualenv - это название пакета виртуального окружения

Итак, я создал каталог на рабочем столе gh_coffee_bot. Открыв его в терминале, я ввожу команду

virtualenv venv

venv - это мое название для созданного виртуального окружения.

Внутри gh_coffee_bot появился каталог venv.

Также я создал внутри каталога gh_coffee_bot еще один с названием app. В нем я храню файлы с ботом.

Но установить вирт.окружение мало, надо его активировать. В каталоге gh_coffee_bot в терминале достаточно прописать

source venv/bin/activate

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

Также можно посмотреть, что уже установлено командой

pip3 freeze

Вирт.окружение у нас пустое, поэтому там ничего не будет.

Далее я установил саму библиотеку pyTelegramBotAPI

pip3 install pyTelegramBotAPI

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

bot.py
import telebot

bot = telebot.TeleBot(тут мой токер)

@bot.message_handler(content_type=['text'])
def echo_message(message):
    bot.send_message(message.from_user.id, 'Привет, друже')

Не знаю, стоит ли пояснять все детально. Здесь просто идет импорт библиотеки на 1 строчке, на 3 я создал экземпляр класса TeleBot(), куда передал свой токен в виде строки, а с 5-7 строки начинают творить магию, о ней будет ниже. Могу посоветовать прочитать официальную документацию и повторить все примеры.

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

Во-первых, пришла в голову мысль создать файл config.py, куда я отдельно записал свой token, а также пару функций, которые напрямую к боту не относятся (но о функциях позже).

config.py
from telebot import types

TOKEN = 'ваш токен'

Во-вторых, у меня будет БД, значит надо создать отдельный файл для работы с ней. Пусть пока что мой бот будет просто сохранять всех новых пользователей, а именно их first_name и last_name (возможно, это и бессмысленно, потому что юзеры могут их спокойно менять, но пока что пусть будут). Также обязательно буду сохранять их user_id, он у каждого уникальный в тг, значит по этому параметру мы потом будет к этим юзерам обращаться и получать какие-либо требуемые данные. Не помешает сохранять время "регистрации" пользователям, то есть когда он добавляется в БД. И также я добавлю пользователю еще один параметр state, об этом я опишу чуть позже.

Для работы с MongoDB я разумеется установил саму mongo (команда актуальна для macOS)

brew install mongodb

Установил пакет pymongo.

pip3 install pymongo

Также я скачал программу Robo 3T, чтобы можно было визуально работать с базой данных. Чтобы запустить установленную БД, ввел

mongod

Создал файл db_users.py (это все в каталоге app), где подключился к БД и написал три функции

db_users.py
import pymongo
from pymongo import MongoClient
from datetime import datetime
import config

client = MongoClient('localhost', 27017)

db = client['gh_coffee_db']


def check_and_add_user(message):
    if db.users.find_one({'user_id': message.from_user.id}) == None:
        new_user = {
            'first_name': message.from_user.first_name,
            'last_name': message.from_user.last_name,
            'user_id': message.from_user.id,
            'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            'state': 'старт'
        }
        db.users.insert_one(new_user)
    return

def get_current_state(user_id):
    user = db.users.find_one({'user_id':user_id})
    return user['state']


def set_state(user_id, state_value):
    db.users.update_one({'user_id': user_id}, {"$set": {'state': state_value}})

Логика довольно простая: check_and_add_user(message) проверяет, есть ли пользователь в нашей БД по его id, который получаем при помощи message.from_user.id, get_current_state(user_id) позволяет нам получить текущее состояние пользователя (значение параметра state), ну и при помощи set_state() мы устанавливаем пользователю новое состояние. На самом деле с "состояниями" я немного поторопился. Зачем они вообще нужны и в чем их логика? Пока что я скажу так - забудьте на время про них вообще, когда я дойду до этапа, на котором я до них додумался, я про них расскажу.

Теперь мы может перейти в наш файл bot.py, и начинать писать что-то серьезное. Хотя не совсем. Я вроде понимаю, что должен делать бот: сначала выбирать общий раздел напитков, потом выбирать конкретный напиток, после выбор размера. После всего в идеале товар добавляется в корзину и так можно выбрать несколько, а потом уже оплатить. Но сколько всего разделов, сколько напитков/сендвичей и прочей продукции. В каком виде вообще хранить эту информацию?

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

Таким образом я создал еще один файл в каталоге app под названием gh_menu.py. В нем я создал словарь gh_menu, куда добавил часть меню:

gh_menu.py
gh_menu = {
    'Особые напитки': {
        'Латте Лаванда Шалфей': {
            '300 мл - 139 руб': '139',
            '400 мл - 169 руб': '169'
        },
        'Раф Лимонный Пай': {
            '300 мл - 159 руб': '159',
            '400 мл - 179 руб': '179'
        },
        'Раф Шоколадный трюфель': {
            '300 мл - 159 руб': '159',
            '400 мл - 179 руб': '179'
        },
        'Кедровый раф': {
            '300 мл - 169 руб': '169',
            '400 мл - 219 руб': '219'
        },
        'Капучино Соленая карамель': {
            '300 мл - 139 руб': '139',
            '400 мл - 169 руб': '169'
        },
        'Зеленый капучино': {
            '300 мл - 169 руб': '169',
            '400 мл - 239 руб': '239'
        }
    },
    'Кофе': {
        'Капучино': {
            '300 мл - 119 руб': '119',
            '400 мл - 149 руб': '149'
        },
        'Латте Макиато': {
            '300 мл - 119 руб': '119',
            '400 мл - 139 руб': '139'
        },
        'Раф': {
            '300 мл - 149 руб': '149',
            '400 мл - 169 руб': '169'
        },
        'Айс Крим Латте': {
            '300 мл - 129 руб': '129',
            '400 мл - 149 руб': '149'
        },
        'Мокко': {
            '400 мл - 179 руб': '149'
        }
    },
    'Горячие напитки': {
        'Какао': {
            '300 мл - 119 руб': '119',
            '400 мл - 149 руб': '149'
        },
        'Чай': {
            'Английский завтрак': {
                '300 мл - 99 руб': '99',
                '400 мл - 109 руб': '109'
            },
            'Эрл грей': {
                '300 мл - 99 руб': '99',
                '400 мл - 109 руб': '109'
            },
            'Моргентау': {
                '300 мл - 99 руб': '99',
                '400 мл - 109 руб': '109'
            },
            'Жасмин голд': {
                '300 мл - 99 руб': '99',
                '400 мл - 109 руб': '109'
            },
            'Апельсин ройбуш': {
                '300 мл - 99 руб': '99',
                '400 мл - 109 руб': '109'
            }
        },
        'Горячее молоко': {
            '300 мл - 99 руб': '99',
            '400 мл - 119 руб': '119'
        }
    }
}

Честно говоря я не знаю пока, насколько удачно я придумал это. Время покажет...

Ну теперь то я могу перейти в файл bot.py и начать кодить.

Импортировал все необходимые модули

bot.py
import telebot
from telebot import types
from gh_menu import gh_menu
import config
import db_users

Создание объекта бота теперь немного изменилось

bot.py
bot = telebot.TeleBot(config.TOKEN)

Командой config.TOKEN мы обращаемся к нашему модулю config к созданной переменной TOKEN.

Сначала нашего пользователя надо поприветствовать и коротко объяснить суть происходящего. Для этого создал функцию send_welcome(message) , которая будет вызываться тогда, когда пользователь первый раз зайдет в бота, ну либо введет команду /start

bot.py
text_messages = {
    'start':
        u'Приветствую тебя, {name}!\n'
        u'Я помогу тебе сделать онлайн заказ (это быстро и без очереди).\n\n'
        u'1. Выбери интересующий напиток/сэндвич/десерт (Ты можешь выбрать несколько)\n'
        u'2. Выбери время, когда захочешь забрать заказ\n'
        u'3. Оплати заказ (это безопасно)\n'
        u'4. Обязательно забери заказ вовремя',
    
    'help':
        u'Пока что я не знаю, чем тебе помочь, поэтому просто выпей кофе!'
}

@bot.message_handler(commands=['start'])
def send_welcome(message):
    db_users.check_and_add_user(message)

    markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    markup.row('Сделать заказ')
    bot.send_message(message.from_user.id, text_messages['start'].format(name=message.from_user.first_name), reply_markup=markup)

В созданной функции мы как раз таки первым делом проверяем, есть данный пользователь или нет, и если нет, то добавляем его (это 16 строчка). 18-19 строчки отвечают за создание кастомной клавиатуры, в моем случае будет лишь одна кнопка "сделать заказ". На 20 строчке мы вызываем объект bot, у него есть метод send_message() куда мы первым должны передать id нашего пользователя (некоторые делают это так message.chat.id, но я считаю правильнее получать именно id пользователя, а не чата), также надо передать текст сообщения, но так как мое сообщение длинное, для эстетического баланса я создал словарь text_messages, у которого есть два ключа start и help. Так как мы отвечаем на пользовательскую команду /start, то и вызываем значение по этому ключу. Методом format я лишь передаю в этот словарь имя нашего пользователя, которое содержится в объекте message. Третий параметр reply_markup опциональный, но так как я создал клавиатуру, то передаю ее reply_markup=markup.

Вообще в хэндлере @bot.message_handler() я могу написать разные условия: например, если мне надо, чтобы бот реагировал на команды через /, типа /start или /help, то пропишу @bot.message_handler(commands=['start', 'help']) , или могу разделить и написать для каждой команды отдельно. Если я хочу, чтобы бот реагировал на текстовое сообщение/эмодзи (а эмодзи, это тоже текст, если я не ошибаюсь), то пропишу @bot.message_handler(content_types=['text']) , и по аналогии вместо text можно вставлять аудио, документы и прочие объекты тг. Функционал моего бота пока ограничится текстом.

Теперь мне надо мою идею из головы как-то конвертировать в алгоритм хотя бы на бумаге, а в итоге в код. Изначально пользователь видит приветствие бота, читает инструкцию и жмет на кнопку "Сделать заказ". Ему должно показаться меню с выбором категорий, типа

  • Особые напитки

  • Кофе

  • Горячие напитки

При выборе категории пользователю надо показать меню с перечнем конкретных товаров. К примеру, если это будут "Особые напитки", то надо отобразить:

  • Латте Лаванда Шалфей

  • Раф Лимонный Пай

  • Раф Шоколадный трюфель

  • Кедровый раф

  • Капучино Соленая карамель

  • Зеленый капучино

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

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

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

  1. Этап - пользователь зашел в бота либо нажал /start

  2. Этап - выбирает категорию

  3. Этап - выбирает конкретный продукт выбранной категории

  4. Этап - Выбирает размер напитка/вид сэндвича и тп

  5. Этап - по идее выбор добавляется в корзину (надо потом в БД создать отдельную переменную и сохранять туда), после чего можно предложить еще что-нибудь, циклично вернув на 2 этап, либо оплата

  6. Оплата

В голову сразу идея приходит - для каждого этапа напишу свою функцию. В итоге файл bot.py выглядел примерно так:

bot.py
### выше предыдущий код

@bot.message_handler(content_type=['text'])
def choose_categories(message):
    cat_markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    cat_markup.row('Особые напитки', 'Кофе')
    cat_markup.row('Горячие напитки', 'еще что-то')
    msg = bot.send_message(message.from_user.id, 'Что вас интересует?', reply_markup=cat_markup)
    bot.register_next_step_handler(msg, choose_drink)

    
@bot.message_handler(content_type=['text'])
def choose_drink(message):
    drink_markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    
    if message.text == 'Особые напитки':
        drink_markup.row('Латте Лаванда Шалфей', 'Раф Лимонный Пай')
        drink_markup.row('Раф Шоколадный трюфель','Кедровый раф')
        drink_markup.row('Капучино Соленая карамель', 'Зеленый капучино')
    elif message.text == 'Кофе':
        drink_markup.row('Капучино', 'Латте Макиато', 'Раф')
        drink_markup.row('Айс Крим Латте', 'Мокко')
    elif message.text == 'Горячие напитки':
        drink_markup.row('Какао', 'Чай', 'Горячее молоко')
    drink_markup.row('Назад')

    msg = bot.send_message(message.from_user.id, 'Выберите напиток', reply_markup=drink_markup)
    bot.register_next_step_handler(msg, choose_size)


@bot.message_handler(content_type=['text'])
def choose_size(message):
    size_markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    if message.text == 'Назад':
        choose_categories(message)
        return 
    else:
        if message.text == 'Латте Лаванда Шалфей':
            size_markup.row('300 мл - 139 руб', '400 мл - 169 руб')
        elif message.text == 'Раф Лимонный Пай':
            size_markup.row('300 мл - 159 руб', '400 мл - 179 руб')
        elif message.text == 'Раф Шоколадный трюфель':
            size_markup.row('300 мл - 159 руб', '400 мл - 179 руб')
        elif message.text == 'Кедровый раф':
            size_markup.row('300 мл - 169 руб', '400 мл - 219 руб')
        elif message.text == 'Капучино Соленая карамель':
            size_markup.row('300 мл - 139 руб', '400 мл - 169 руб')
        elif message.text == 'Зеленый капучино'
            # здесь мне показалось, что я делаю что-то жесткое, поэтому
            # перестал создавать оставшиеся elifы
        size_markup.row('Назад')
        msg = bot.send_message(message.from_user.id, 'Выбери размер', reply_markup=size_markup)


def get_paid(message):
    # До оплаты еще далеко, поэтому эту функцию описывать сейчс смысла нет
    pass



bot.polling()

Что можно видеть? Все, как и описывал выше. Для 2 этапа написал функцию def choose_categories(message) , для 3 этапа def choose_drink(message), и затронул немного 4 этап def choose_size(message). Остановился, чтобы проанализировать, что я имею. Еще забыл упомянуть, что наличие хэндлеров подразумевает, что вызовется та функция, которая первая подходит по условию. Конечно, у меня сейчас везде один и тот же хэндлер, что мне уже не нравится, но это стоит учитывать, потому что изначально я этого не сделал.

Для того, чтобы из одной функции вызвать другую, можно использовать register_next_step_handler() , куда надо передать объект message и, собственно, название самой функции. В принципе, в таком случае вообще можно оставить хэндлер только у функции choose_categories() , а остальные убрать. Но такая концепция подошла бы, если путь в боте был прямолинейный. А ведь пользователь может ошибиться и захотеть вернуться назад, и тут register_next_step_handler() терпит крах. Что я имею ввиду: допустим юзер уже зашел в категорию, выбирает кофе, но передумал и хочет вернуться опять в "категории". На словах все просто, а как дела с кодом обстоят? Тут небольшое безумие. В боте кнопки кнопками как таковыми не являются, то есть кнопки - это не отдельный объект, это просто удобный для пользователя интерфейс. Мы, как программист, пишем программу, которая умеет работать только с определенным набором команд (вводимым пользователем текстом). И мы эти команды ждем от пользователям, поэтому и создаем кнопки, чтобы пользователь мог, нажав на них, отправить нужный нам текст. Это не кнопки, как в том же веб-программировании.

Это я все пишу для того, чтобы дать понять, что нажав на кнопку, юзер точно также отправляет текст, как если бы он просто его ввел. Ну это наверное и так очевидно. И когда наш пользователь, допустим, находится в функции выбора напитков choose_drink() и жмет на кнопку назад, чтобы перейти снова к выбору категорий (для нас это функция choose_categories() ) то по факту он все равно сначала попадет в функцию choose_size(), потому что обработать отправленный текст, насколько я понял, можно только в следующей функции.

И, как можно видеть, я как раз добавил в самом начале choose_size() условие if

bot.py
@bot.message_handler(content_type=['text'])
def choose_size(message):
    size_markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    if message.text == 'Назад':
        choose_categories(message)
        return 
    else:
    # продолжение

Если полученный текст message.text равен "назад", то просто вызываю функцию получения категорий. Сначала я пытался это провернуть путем вызова register_next_step_handler() , но возникал баг: чтобы перейти назад, надо было боту отправить два раза что-нибудь (то есть сначала ты тыкаешь "назад", бот замирает, потом еще раз что-нибудь пишешь, и он отправлял в функцию выбора категорий). Так что и не пытайтесь делать также (разве только в качестве опыта).

Работа над ошибками

Первое, что можно заметить, это повтор кода. Прямое нарушение принципа DRY. В каждой функции я заново прописываю создание объекта кастомной клавиатуры, создаю отдельные кнопки путем markup.row('что-то') . Разумеется, на ум приходит идея написать функцию, которая бы сама создавала клавиатуру, а мы бы просто туда передавали название кнопок.

Для этого я создал в файле config.py функцию create_menu()

config.py
def create_menu(mass, back=True):
    """
    This function allows to creat menu of buttons.
    mass - the list of string
    back - back button, if true, add a button back. Default back=True
    """
    markup = types.ReplyKeyboardMarkup(resize_keyboard=True)

    if len(mass) == 1:
        markup.row(mass[0])
    else:
        while len(mass) > 0:
            try:
                cut = mass[:2]
                markup.row(cut[0], cut[1])
                del mass[:2]
                
                if len(mass) == 1:
                    markup.row(mass[0])
                    break
            except:
                print('WTF')
    

    if back == True:
        markup.row('Назад')
    
    return markup

Итак. Функция требует два параметра: mass - это список наших кнопок по типу ['Горячие напитки', 'Кофе', 'Особые напитки'] , back - это булевская переменная, если она будет  True, то в наше меню будет добавляться кнопка "Назад". Она нам может быть не всегда нужна, поэтому это вполне логичное решение.

Сразу оговорюсь, что на мой взгляд оптимальным будет сделать клаву в 2 столбца. В 1 слишком широко, в 3 надписи не влезают. А мы все таки хотим, чтобы интерфейс был приятным.

На 7 строке создал объект клавиатуры. Далее начинаю проверять, если массив содержит лишь один элемент, то просто его добавляем и на этом все. Если же >1, то мы переходим в цикл While, где просто делаем срез полученного списка на 14 строке (первые два элемента), добавляем элементы среза в клавиатуру и удаляем его(это строки 15-16). И так циклично, пока длина массива элементов не станет равной единице. В этом случае сработает условие на 18 строке, добавится последний элемент и произойдет выход из цикла. И на 25 строке произойдет проверка, если back=True , то в клаву еще добавиться кнопка "Назад".

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

Теперь хочу приоткрыть тайну загадочного поля "state" у наших юзеров. Вот так сейчас выглядит юзер (это я) в БД

И хотя я могу называть юзеров объектами, с точки зрения самой БД наверное правильно называть их документами. MongoDB является документоориентированной БД. И при работе с ней вы можете каждый свой "объект" сохранять в виде документа. Документы хранятся в коллекциях. В моей случае документы юзеров хранятся в коллекции users. Не буду сильно углубляться в структуру Монги, прочтите сами и поймете, о чем я говорю.

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

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

Теперь хочу снова обратить внимание на файл db_users.py

db_users.py
import pymongo
from pymongo import MongoClient
from datetime import datetime
import config

client = MongoClient('localhost', 27017)

db = client['gh_coffee_db']


def check_and_add_user(message):
    if db.users.find_one({'user_id': message.from_user.id}) == None:
        new_user = {
            'first_name': message.from_user.first_name,
            'last_name': message.from_user.last_name,
            'user_id': message.from_user.id,
            'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            'state': 'старт'
        }
        db.users.insert_one(new_user)
    return

def get_current_state(user_id):
    user = db.users.find_one({'user_id':user_id})
    return user['state']


def set_state(user_id, state_value):
    db.users.update_one({'user_id': user_id}, {"$set": {'state': state_value}})

Тут я и работаю с состояниями. Я даже еще раз повторю логику функционала. А Логика довольно простая: check_and_add_user(message) проверяет, есть ли пользователь в нашей БД по его id, который получаем при помощи message.from_user.id. Если пользователя нет, то добавляем new_user. get_current_state(user_id) позволяет нам получить текущее состояние пользователя (значение параметра state), ну и при помощи set_state() мы устанавливаем пользователю новое состояние.

Сами состояния я пока храню в виде констант в файле config.py по типу:

config.py
from telebot import types

TOKEN = 'ваш токен'

# КОНСТАНТЫ СОСТОЯНИЙ
S_GET_CAT = 'Список категорий'
S_CHOOSE_CAT = 'Выбор категории'

# КОНСТАНТЫ КАТЕГОРИЙ
S_SPECIAL_DRINKS = 'Особые напитки'
S_COFFEE = 'Кофе'
S_HOT_DRINKS = 'Горячие напитки'

# КОНСТАНТЫ ТОВАРОВ
S_CHOOSE_GOOD = 'Выбор товара'

S_LATTE_LAVANDA_SHALFEI = 'Латте Лаванда Шалфей'
S_RAF_LEMON_PIE = 'Раф Лимонный Пай'
S_RAF_CHOCOLATE_TRUFEL = 'Раф Шоколадный трюфель'
S_KEDROVI_RAF = 'Кедровый раф'
S_KAPUCHINO_SALT_KARAMEL = 'Капучино Соленая карамель'
S_GREEN_KAPUCHINO = 'Зеленый капучино'

S_KAPUCHINO = 'Капучино'
S_LATTE_MAKIATO = 'Латте Макиато'
S_RAF = 'Раф'
S_ICE_CREAM_LATTE= 'Айс Крим Латте'
S_MOKKO = 'Мокко'

S_KAKAO = 'Какао'
S_TEA = 'Чай'
S_HOT_MILK = 'Горячее молоко'

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

Ну хорошо, теперь стоит показать вам как преобразился файл bot.py

bot.py
import telebot
from telebot import types
from gh_menu import gh_menu
import config
import db_users
from config import create_menu

bot = telebot.TeleBot(config.TOKEN)

text_messages = {
    'start':
        u'Приветствую тебя, {name}!\n'
        u'Я помогу тебе сделать онлайн заказ (это быстро и без очереди).\n\n'
        u'1. Выбери интересующий напиток/сэндвич/десерт (Ты можешь выбрать несколько)\n'
        u'2. Выбери время, когда захочешь забрать заказ\n'
        u'3. Оплати заказ (это безопасно)\n'
        u'4. Обязательно забери заказ вовремя',
    
    'help':
        u'Пока что я не знаю, чем тебе помочь, поэтому просто выпей кофе!'
}

@bot.message_handler(commands=['start'])
def send_welcome(message):
    db_users.check_and_add_user(message)

    markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    markup.row('Сделать заказ')
    bot.send_message(message.from_user.id, text_messages['start'].format(name=message.from_user.first_name), reply_markup=markup)
    db_users.set_state(message.from_user.id, config.S_GET_CAT)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_GET_CAT)
def get_categories(message):
    """
    This function allows to get a list of categories
    """
    user_id = message.from_user.id
    mass = list(gh_menu.keys())
    markup = create_menu(mass, back=False)
    bot.send_message(user_id, 'Что вас интересует?', reply_markup=markup)
    db_users.set_state(user_id, config.S_CHOOSE_CAT)


# !!!  ЭТО ПРОМЕЖУТОЧНАЯ ФУНКЦИЯ  !!!
@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_CHOOSE_CAT)
def choose_categories(message):
    """
    This function allows to choose a category
    """
    user_id = message.from_user.id

    if message.text == 'Особые напитки':
        db_users.set_state(user_id, config.S_SPECIAL_DRINKS)
        get_special_drinks(message)
    elif message.text == 'Кофе':
        db_users.set_state(user_id, config.S_COFFEE)
        get_coffee(message)
    elif message.text == 'Горячие напитки':
        db_users.set_state(user_id, config.S_HOT_DRINKS)
        get_hot_drinks(message)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_SPECIAL_DRINKS)
def get_special_drinks(message):
    """
    This function allows to get a list of special drinks
    """
    user_id = message.from_user.id
    mass = list(gh_menu['Особые напитки'])
    markup = create_menu(mass)
    bot.send_message(user_id, 'Выберите напиток', reply_markup=markup)
    db_users.set_state(user_id, config.S_CHOOSE_GOOD)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_COFFEE)
def get_coffee(message):
    """
    This function allows to get a list of coffee
    """
    user_id = message.from_user.id
    mass = list(gh_menu['Кофе'])
    markup = create_menu(mass)
    bot.send_message(user_id, 'Выберите напиток', reply_markup=markup)
    db_users.set_state(user_id, config.S_CHOOSE_GOOD)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_HOT_DRINKS)
def get_hot_drinks(message):
    """
    This function allows to get a list of hot drinks
    """
    user_id = message.from_user.id
    mass = list(gh_menu['Горячие напитки'])
    markup = create_menu(mass)
    bot.send_message(user_id, 'Выберите напиток', reply_markup=markup)
    db_users.set_state(user_id, config.S_CHOOSE_GOOD)


# ЭТО ПРОМЕЖУТОЧНАЯ ФУНКЦИЯ
@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_CHOOSE_GOOD)
def choose_good(message):
    """
    This function allows to choose a goods
    """
    user_id = message.from_user.id

    if message.text == "Назад":
        db_users.set_state(user_id, config.S_GET_CAT)
        get_categories(message)
    if message.text == 'Латте Лаванда Шалфей':
        db_users.set_state(user_id, config.S_LATTE_LAVANDA_SHALFEI)
    elif message.text == 'Раф Лимонный Пай':
        db_users.set_state(user_id, config.S_RAF_LEMON_PIE)
    elif message.text == 'Раф Шоколадный трюфель':
        db_users.set_state(user_id, config.S_RAF_CHOCOLATE_TRUFEL)
    elif message.text == 'Кедровый раф':
        db_users.set_state(user_id, config.S_KEDROVI_RAF)
    elif message.text == 'Капучино Соленая карамель':
        db_users.set_state(user_id, config.S_KAPUCHINO_SALT_KARAMEL)
    elif message.text == 'Зеленый капучино':
        db_users.set_state(user_id, config.S_GREEN_KAPUCHINO)
    elif message.text == 'Капучино':
        db_users.set_state(user_id, config.S_KAPUCHINO)
    elif message.text == 'Латте Макиато':
        db_users.set_state(user_id, config.S_LATTE_MAKIATO)
    elif message.text == 'Раф':
        db_users.set_state(user_id, config.S_RAF)
    elif message.text == 'Айс Крим Латте':
        db_users.set_state(user_id, config.S_ICE_CREAM_LATTE)
    elif message.text == 'Мокко':
        db_users.set_state(user_id, config.S_MOKKO)
    elif message.text == 'Какао':
        db_users.set_state(user_id, config.S_KAKAO)
    elif message.text == 'Чай':
        db_users.set_state(user_id, config.S_TEA)
    elif message.text == 'Горячее молоко':
        db_users.set_state(user_id, config.S_HOT_MILK)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_LATTE_LAVANDA_SHALFEI)
def get_latte_lavanda_shalfei(message):
    user_id = message.from_user.id
    mass = list(gh_menu['Особые напитки']['Латте Лаванда Шалфей'])
    markup = create_menu(mass)
    bot.send_message(user_id, 'Выберите размер', reply_markup=markup)
    db_users.set_state(user_id, )

# Еще писать и писать...

bot.polling()

Поясню некоторые моменты, начиная сверху.

В функции send_welcome() добавилась строка 30, где я из модуля db_users вызываю функцию set_state чтобы установить новое состояние, следуя философии, описанной выше. Вызов этой функции вы можете наблюдать в конце каждой функции файла bot.py либо условия if, ну это логично. Как только функция отработала, нам надо установить новое состояние пользователя и исходя из него будет вызвана следующая функция. Но как именно? А вы обратили внимание, что теперь у каждой функции поменялся хэндлер (к примеру строка 33). Вот как он сейчас выглядит

@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_GET_CAT)

Теперь в хэндреле происходит получение текущего состояния пользователя из БД путем вызова функции get_current_state() в модуле db_users. И если полученное состояние равно необходимому нам, в данном случае состояние вызова категорий, полученное из модуля (файла) config.py.

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

Также я бы обратил ваше внимание на строку 40, где я создаю меню при помощи написанной мной же функции create_menu() , и туда передаю список, так сказать, слов (строк), которые в меню должны отображаться. И я не создаю новый список с этими строками, а делаю волшебство на строке 39. Ведь у нас есть файл gh_menu.py, в котором создан одноименный словарь gh_menu. Я просто получил все его ключи. Опять же преимущество в том, что при правках в меню, мне стоит поменять лишь словарь gh_menu, при этом не беспокоясь о клавиатуре.

По ходу написания кода я понял одну вещь: вот мы в функции get_categories()показали пользователю меню категорий. Каждая категория подразумевают собой еще функцию с отрисовкой другого меню для пользователя. Но как из первой функции вызвать вторую, причем надо вызвать нужную, то есть при выборе "особых напитков" к примеру надо вызвать get_special_drinks() и тп. Для этого пришлось написать еще одну функцию choose_categories() на строке 47, прокоментить как промежуточную функцию, ведь в ней по факту просто происходит условие выбора. Такой же промежуточной функцией является и choose_good()(то есть выбор товара) на 102 строке, где точно также происходит выбор товара.

И обратите внимание, что там реализация кнопки "Назад", которая работает адекватно.

bot.py
    if message.text == "Назад":
        db_users.set_state(user_id, config.S_GET_CAT)
        get_categories(message)

То есть если из какой-либо из функций выбора товара пользователь захотел вернуться назад к категориям, то обработка происходит именно тут. Я на 2 строке перезаписываю состояние пользователя в БД и на 3 строке вызываю функцию категорий.

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

Щепотка абстракции

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

Во-первых, я слишком сильно заострил внимание на содержании бота. Какой смысл было добавлять столько товара в каждую категорию в файле gh_menu? Ведь моя задача на данном этапе заключается, грубо говоря, в проверке гипотезы - нужен ли этот бот людям, в частности кофейням. Исходя из этого я немного подредактировал файл gh_menu.py, просто оставив везде по два товара

gh_menu.py
gh_menu = {
    'Особые напитки': {
        'Латте Лаванда Шалфей': {
            '300 мл - 139 руб': '139',
            '400 мл - 169 руб': '169'
        },
        'Раф Лимонный Пай': {
            '300 мл - 159 руб': '159',
            '400 мл - 179 руб': '179'
        }
    },
    'Кофе': {
        'Капучино': {
            '300 мл - 119 руб': '119',
            '400 мл - 149 руб': '149'
        },
        'Латте Макиато': {
            '300 мл - 119 руб': '119',
            '400 мл - 139 руб': '139'
        }
    },
    'Горячие напитки': {
        'Какао': {
            '300 мл - 119 руб': '119',
            '400 мл - 149 руб': '149'
        },
        'Чай': {
            'Английский завтрак': {
                '300 мл - 99 руб': '99',
                '400 мл - 109 руб': '109'
            },
            'Эрл грей': {
                '300 мл - 99 руб': '99',
                '400 мл - 109 руб': '109'
            }
        }
    }
}

Во-вторых, я слишком париался по поводу "неидеальности" кода. Да, писать только на "говне и костылях" не лучший вариант. Но и программирование через призму перфекционизма, опять же, на данном этапе лишнее. Пусть где-то много кода. Его наверное всегда будет много. И всегда можно сделать эффективнее и короче. Но опять же - на данном этапе надо делать как знаешь. Вот когда я покажу прототип кофейням, кто-то заинтересуется, то и стоит об это задуматься.

И все-таки мне удалось поработать с объектами, которые уменьшили как количество кода, так и его дублирование. Сперва напомню, что в файле bot.py функции получения товаров категорий типа get_special_drinks() , get_coffee(), get_hot_drinks() практически идентичны. И пусть они по объему маленькие, но что если бы каждая функция была по 100 строк? Именно это дело я и подкорректировал.

Отличие всех трех функций в том, что мы получаем получаем разные данные из словаря gh_menu типа gh_menu['сюда передаем категорию'], и в разных состояниях, которые мы передаем в конце функций db_users.set_state(user_id, config.<сюда состояние>.

Поэтому я перешел в файл config.py и создал после всего имеющегося класс Goods.

config.py
# выше код...

class Goods:
    def __init__(self, bot, message, state):
        self.bot = bot
        self.message = message
        self.state = state

    def get_goods_list(self):
        """
        This function allows to get a list of special drinks
        """
        user_id = self.message.from_user.id
        mass = list(gh_menu[self.message.text])
        markup = create_menu(mass)
        self.bot.send_message(user_id, 'Выберите напиток', reply_markup=markup)
        db_users.set_state(user_id, self.state)

Класс имеет конструктор def __init__(), куда передаются следующие параметры:

  • self - это ссылка на экземпляр этого класса. В python он является обязательным, если вы работаете с классом. Если не в курсе про self, то погуглите, это не сложная, но важная тема.

  • bot - это объект нашего бота, который мы создаем в самом начале файла bot.py (bot = telebot.TeleBot(config.TOKEN) )

  • message - это объект библиотеки pyTelegramBotAPI, он нужен для получения данных о пользователе, отправленным им текстом и еще много всего.

  • state - состояние пользователя, которое мы хотим ему присвоить

Также класс имеет функцию get_goods_list(), в которую я и вынес весь функционал. Теперь я редактирую функции в bot.py

bot.py
# выше код...

@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_SPECIAL_DRINKS)
def get_special_drinks(message):
    """
    This function allows to get a list of special drinks
    """
    special_drinks = Goods(bot, message, config.S_CHOOSE_GOOD)
    special_drinks.get_goods_list()


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_COFFEE)
def get_coffee(message):
    """
    This function allows to get a list of coffee
    """
    coffee = Goods(bot, message, config.S_CHOOSE_GOOD)
    coffee.get_goods_list()


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_HOT_DRINKS)
def get_hot_drinks(message):
    """
    This function allows to get a list of hot drinks
    """
    hot_drinks = Goods(bot, message, config.S_CHOOSE_GOOD)
    hot_drinks.get_goods_list()

    # ниже код...

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

А что касается логики, то в каждой функции я создаю сначала экземпляр класса Goods (главное не забыть его импортировать from config import Goods ), передаю в его конструктор необходимые параметры, а после вызываю метод этого класса.

Аналогичную логику я применил и для работы с каждым отдельным товаром. Так как класс Goods создан для работы с товаром, то в нем же создаю еще один метод get_current_good(), который позволит получать характеристики отдельного товара.

config.py
# выше код...

class Goods:
    def __init__(self, bot, message, state):
        self.bot = bot
        self.message = message
        self.state = state

    def get_goods_list(self):
        """
        This function allows to get a list of special drinks
        """
        user_id = self.message.from_user.id
        mass = list(gh_menu[self.message.text])
        markup = create_menu(mass)
        self.bot.send_message(user_id, 'Выберите напиток', reply_markup=markup)
        db_users.set_state(user_id, self.state)

    def get_current_good(self, cat):
        """
        This function allows to get a current good
        """
        user_id = self.message.from_user.id
        mass = list(gh_menu[cat][self.message.text])
        markup = create_menu(mass)
        self.bot.send_message(user_id, 'Выберите размер', reply_markup=markup)
        db_users.set_state(user_id, self.state)

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

Содержание каждого из файлов на данных момент

Я посчитал нужным подвести промежуточный итог, показав содержимое каждого файла, на случай, если кто-то где-то потерялся, либо я случайно о чем-то умолчал:)

Сейчас бот имеет 4 файла

  • bot.py

  • config.py

  • gh_menu.py

  • db_users.py

bot.py
import telebot
from telebot import types
from gh_menu import gh_menu
import db_users
from config import create_menu, Goods
import config

bot = telebot.TeleBot(config.TOKEN)

text_messages = {
    'start':
        u'Приветствую тебя, {name}!\n'
        u'Я помогу тебе сделать онлайн заказ (это быстро и без очереди).\n\n'
        u'1. Выбери интересующий напиток/сэндвич/десерт (Ты можешь выбрать несколько)\n'
        u'2. Выбери время, когда захочешь забрать заказ\n'
        u'3. Оплати заказ (это безопасно)\n'
        u'4. Обязательно забери заказ вовремя',
    
    'help':
        u'Пока что я не знаю, чем тебе помочь, поэтому просто выпей кофе!'
}


@bot.message_handler(commands=['start'])
def send_welcome(message):
    db_users.check_and_add_user(message)

    markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    markup.row('Сделать заказ')
    bot.send_message(message.from_user.id, text_messages['start'].format(name=message.from_user.first_name), reply_markup=markup)
    db_users.set_state(message.from_user.id, config.S_GET_CAT)


@bot.message_handler(commands=['help'])
def send_help(message):
    markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    markup.row('Назад')
    msg = bot.send_message(message.from_user.id, text_messages['help'], reply_markup=markup)
    bot.register_next_step_handler(msg, send_welcome)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_GET_CAT)
def get_categories(message):
    """
    This function allows to get a list of categories
    """
    user_id = message.from_user.id
    mass = list(gh_menu.keys())
    markup = create_menu(mass, back=False)
    bot.send_message(user_id, 'Что вас интересует?', reply_markup=markup)
    db_users.set_state(user_id, config.S_CHOOSE_CAT)



# !!!  ЭТО ПРОМЕЖУТОЧНАЯ ФУНКЦИЯ  !!!
@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_CHOOSE_CAT)
def choose_categories(message):
    """
    This function allows to choose a category
    """
    user_id = message.from_user.id

    if message.text == 'Особые напитки':
        db_users.set_state(user_id, config.S_SPECIAL_DRINKS)
        get_special_drinks(message)
    elif message.text == 'Кофе':
        db_users.set_state(user_id, config.S_COFFEE)
        get_coffee(message)
    elif message.text == 'Горячие напитки':
        db_users.set_state(user_id, config.S_HOT_DRINKS)
        get_hot_drinks(message)



@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_SPECIAL_DRINKS)
def get_special_drinks(message):
    """
    This function allows to get a list of special drinks
    """
    special_drinks = Goods(bot, message, config.S_CHOOSE_GOOD)
    special_drinks.get_goods_list()


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_COFFEE)
def get_coffee(message):
    """
    This function allows to get a list of coffee
    """
    coffee = Goods(bot, message, config.S_CHOOSE_GOOD)
    coffee.get_goods_list()


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_HOT_DRINKS)
def get_hot_drinks(message):
    """
    This function allows to get a list of hot drinks
    """
    hot_drinks = Goods(bot, message, config.S_CHOOSE_GOOD)
    hot_drinks.get_goods_list()


# ЭТО ПРОМЕЖУТОЧНАЯ ФУНКЦИЯ
@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_CHOOSE_GOOD)
def choose_good(message):
    """
    This function allows to choose a goods
    """
    user_id = message.from_user.id

    if message.text == "Назад":
        db_users.set_state(user_id, config.S_GET_CAT)
        get_categories(message)
    if message.text == 'Латте Лаванда Шалфей':
        db_users.set_state(user_id, config.S_LATTE_LAVANDA_SHALFEI)
        get_latte_lavanda_shalfei(message)
    elif message.text == 'Раф Лимонный Пай':
        db_users.set_state(user_id, config.S_RAF_LEMON_PIE)
        get_raf_lemon_pie(message)
    elif message.text == 'Капучино':
        db_users.set_state(user_id, config.S_KAPUCHINO)
        get_kapuchino(message)
    elif message.text == 'Латте Макиато':
        db_users.set_state(user_id, config.S_LATTE_MAKIATO)
        get_latte_makiato(message)
    elif message.text == 'Какао':
        db_users.set_state(user_id, config.S_KAKAO)
        get_kakao(message)
    elif message.text == 'Чай':
        db_users.set_state(user_id, config.S_TEA)
        get_tea(message)



@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_LATTE_LAVANDA_SHALFEI)
def get_latte_lavanda_shalfei(message):
    """
    This function allows to get a parameters of latte_lavanda_shalfei
    """
    latte_lavanda_shalfei = Goods(bot, message, config.S_LATTE_LAVANDA_SHALFEI)
    latte_lavanda_shalfei.get_current_good(config.S_SPECIAL_DRINKS)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_RAF_LEMON_PIE)
def get_raf_lemon_pie(message):
    """
    This function allows to get a parameters of raf_lemon_pie
    """
    raf_lemon_pie = Goods(bot, message, config.S_RAF_LEMON_PIE)
    raf_lemon_pie.get_current_good(config.S_SPECIAL_DRINKS)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_KAPUCHINO)
def get_kapuchino(message):
    """
    This function allows to get a parameters of kapuchino
    """
    kapuchino = Goods(bot, message, config.S_KAPUCHINO)
    kapuchino.get_current_good(config.S_COFFEE)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_LATTE_MAKIATO)
def get_latte_makiato(message):
    """
    This function allows to get a parameters of latte_makiato
    """
    latte_makiato = Goods(bot, message, config.S_LATTE_MAKIATO)
    latte_makiato.get_current_good(config.S_COFFEE)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_KAKAO)
def get_kakao(message):
    """
    This function allows to get a parameters of kakao
    """
    kakao = Goods(bot, message, config.S_KAKAO)
    kakao.get_current_good(config.S_HOT_DRINKS)


@bot.message_handler(func=lambda message: db_users.get_current_state(message.from_user.id) == config.S_TEA)
def get_tea(message):
    """
    This function allows to get a parameters of tea
    """
    tea = Goods(bot, message, config.S_TEA)
    tea.get_current_good(config.S_HOT_DRINKS)



bot.polling(none_stop=True)

config.py
from telebot import types

import db_users
from gh_menu import gh_menu

TOKEN = 'Ваш токен'


# КОНСТАНТЫ СОСТОЯНИЙ
S_GET_CAT = 'Список категорий'
S_CHOOSE_CAT = 'Выбор категории'

# КОНСТАНТЫ КАТЕГОРИЙ
S_SPECIAL_DRINKS = 'Особые напитки'
S_COFFEE = 'Кофе'
S_HOT_DRINKS = 'Горячие напитки'

# КОНСТАНТЫ ТОВАРОВ
S_CHOOSE_GOOD = 'Выбор товара'

S_LATTE_LAVANDA_SHALFEI = 'Латте Лаванда Шалфей'
S_RAF_LEMON_PIE = 'Раф Лимонный Пай'


S_KAPUCHINO = 'Капучино'
S_LATTE_MAKIATO = 'Латте Макиато'


S_KAKAO = 'Какао'
S_TEA = 'Чай'





#ОСОБЫЕ НАПИТКИ
list_of_latte = ['Латте Лаванда Шалфей']




def create_menu(mass, back=True):
    """
    This function allows to creat menu of buttons.
    mass - the list of string
    back - back button, if true, add a button back. Default back=True
    """
    markup = types.ReplyKeyboardMarkup(resize_keyboard=True)

    if len(mass) == 1:
        markup.row(mass[0])
    else:
        while len(mass) > 0:
            try:
                cut = mass[:2]
                markup.row(cut[0], cut[1])
                del mass[:2]
                
                if len(mass) == 1:
                    markup.row(mass[0])
                    break
            except:
                print('WTF')
    

    if back == True:
        markup.row('Назад')
    
    return markup


class Goods:
    def __init__(self, bot, message, state):
        self.bot = bot
        self.message = message
        self.state = state

    def get_goods_list(self):
        """
        This function allows to get a list of special drinks
        """
        user_id = self.message.from_user.id
        mass = list(gh_menu[self.message.text])
        markup = create_menu(mass)
        self.bot.send_message(user_id, 'Выберите напиток', reply_markup=markup)
        db_users.set_state(user_id, self.state)

    def get_current_good(self, cat):
        """
        This function allows to get a current good
        """
        user_id = self.message.from_user.id
        mass = list(gh_menu[cat][self.message.text])
        markup = create_menu(mass)
        self.bot.send_message(user_id, 'Выберите размер', reply_markup=markup)
        db_users.set_state(user_id, self.state)

gh_menu.py
gh_menu = {
    'Особые напитки': {
        'Латте Лаванда Шалфей': {
            '300 мл - 139 руб': '139',
            '400 мл - 169 руб': '169'
        },
        'Раф Лимонный Пай': {
            '300 мл - 159 руб': '159',
            '400 мл - 179 руб': '179'
        }
    },
    'Кофе': {
        'Капучино': {
            '300 мл - 119 руб': '119',
            '400 мл - 149 руб': '149'
        },
        'Латте Макиато': {
            '300 мл - 119 руб': '119',
            '400 мл - 139 руб': '139'
        }
    },
    'Горячие напитки': {
        'Какао': {
            '300 мл - 119 руб': '119',
            '400 мл - 149 руб': '149'
        },
        'Чай': {
            'Английский завтрак': {
                '300 мл - 99 руб': '99',
                '400 мл - 109 руб': '109'
            },
            'Эрл грей': {
                '300 мл - 99 руб': '99',
                '400 мл - 109 руб': '109'
            }
        }
    }
}

db_users.py
from pymongo import MongoClient
from datetime import datetime

client = MongoClient('localhost', 27017)

db = client['gh_coffee_db']


def check_and_add_user(message):
    if db.users.find_one({'user_id': message.from_user.id}) == None:
        new_user = {
            'first_name': message.from_user.first_name,
            'last_name': message.from_user.last_name,
            'user_id': message.from_user.id,
            'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            'state': 'старт'
        }
        db.users.insert_one(new_user)
    return

def get_current_state(user_id):
    user = db.users.find_one({'user_id':user_id})
    return user['state']


def set_state(user_id, state_value):
    db.users.update_one({'user_id': user_id}, {"$set": {'state': state_value}})

(СТАТЬЯ ЕЖЕДНЕВНО ДОПИСЫВАЕТСЯ И РЕДАКТИРУЕТСЯ)

Last updated