Мои первые шаги
Создано специально для телеграм канала https://t.me/debug_u
Как я учусь делать телеграм бота?
Вступление
Приветствую всех, кто решил прочесть эту статью. В ней я постараюсь изложить все этапы создания бота, все материалы, что я прочитал, все ошибки, которые я совершил и осознал, в общем весь путь. Мое решение может быть плохим, так что я приветствую критику и предложения от более опытных и мудрых людей. Если хотите больше информации о моей деятельности, переходите в телеграм канал Debug_Yourself.
В чем идея бота?
Перед тем, как выбрать идею для бота, я понял, что лучше не пытаться сразу сделать "сложного" бота со сверхбогатым функционалом, потому что сложность на начальном этапе, как правило, всегда убивает энтузиазм и желание заниматься выбранным делом в принципе. Также я хотел сделать что-то более менее пригодное и актуальное, что позволило бы мне освоить весь базовый функционал, необходимый для реальных проектов. Скажем, можно было сделать криптобота, который бы позволял получать цену в $ интересующей крипты. И на самом деле я такой сделал - это слишком просто. Уверенности я в себе не почувствовал уж точно. Такой бот в чистом виде неактуален, хотя врать не буду, я поработал с API коинмаркеткапа, что уже неплохо.
Я люблю выпить ягодный чай в кофейне, где кофе на вынос. Но свой заказ приходится ждать, неважно сколько, иногда время есть только на то, чтобы забежать, сразу взять свой чай и по делам. Но заказы онлайн они не принимают. И сразу идея - сделать бота для онлайн заказов. Это автоматизация бизнеса, что актуально. Это также тот базовый функционал, которые имеют большинство ботов, и более того, это возможно по готовности предлагать владельцам купить такого бота.
В общем решено - делаю бота для онлайн заказа в кофейне
На чем я буду делать бота?
Разумеется, это никакой не конструктор. Я уже писал у себя на канале, почему лучше изучить прогу и написать бота, чем пользоваться конструкторами. Если коротко, то
Так как я немного знаю язык программирования (ЯП) Python, то и писать его буду на нем. Про достоинства этого языка можно написать отдельный пост (ну или прочитать в инете), поэтому сильно его нахваливать не буду. Лишь скажу, что в целом вы можете писать тг ботов на:
PHP
Java
nodejs
Python
C#
Go
еще парочка других
Полный список можете глянуть по ссылке. Там же вы увидите, что у популярных языков есть несколько библиотек. Python в частности имеет
pyTelegramBotAPI (почему-то не указана в этом списке)
Какая библиотека лучше? Разумеется, лично я сказать не могу, так как не имел опыта работы с ними. С другими тоже особо не говорил по этому поводу. Если судить по звездам на гитхабе, то python-telegram-bot обходит остальные. Потом идет Telepot и замыкает тройку pyTelegramBotAPI. Тем не менее я пользуюсь последней. Во многих обучающих примерах использовали именно ее, плюс она показалась довольно простой и привлекательной.
Осталось упомянуть базу данных и используемую среду разработки. Я сразу понял, что без БД никуда, ну точнее можно без нее реализовать бота, но в реальных проектах вряд ли боты обходятся без БД, поэтому я решил ее использовать. MySQL/PostgreSQL я не знаю (за что даже стыдно), был опыт работы с SQLite, правда не в чистом виде, а когда изучал фреймворк Django (об этом как-нибудь потом), но там используется ORM. Если не знаете что такое ORM, могу порекомендовать быстро прочесть тут, мне показалось довольно хорошим такое объяснение.... Возвращаясь к БД, я решил использовать MongoDB. У меня был небольшой опыт работы с ней, когда изучал Node.js, она мне сразу понравилась, и мне показалось, что она вполне годится для моего бота.
В качестве среды разработки я использую Visual Studio Code. Опять же сред много, вы найдете свою только когда попробуете несколько.
Первые шаги
Разумеется, изначально я посмотрел документацию выбранной библиотеки - это надо стараться делать первым делом, когда беретесь изучать что-то новое. Но и это правило имеет исключения. По моему мнению хорошая документацию должна содержать не только список имеющихся в ней "вещей", а непосредственно примеры их использования. Когда автор описывает способы реализации своих функций и дает к ним пояснения, он показывает свое видение. Тебе, как ученику, становится легче проследить его логику и понять надобность. Я читал документацию по 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 и сказали следовать инструкции (она на сайте подробно описана, также я для своих подписчиков на всякий случай описал свою инструкцию).
Скачать прогу в зависимости от вашей ОС
Далее выбираем понравившийся сервер
Качаем файл с настройками.
Вводим логин 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, это будет главный исполняемый файл. В нем сначала импортировал все требуемые модули и попробовал написать просто бота, который бы отвечал нам на любое наше сообщение, дабы удостовериться, что все будет работать дальше.
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, а также пару функций, которые напрямую к боту не относятся (но о функциях позже).
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), где подключился к БД и написал три функции
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 = {
'Особые напитки': {
'Латте Лаванда Шалфей': {
'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 и начать кодить.
Импортировал все необходимые модули
import telebot
from telebot import types
from gh_menu import gh_menu
import config
import db_users
Создание объекта бота теперь немного изменилось
bot = telebot.TeleBot(config.TOKEN)
Командой config.TOKEN
мы обращаемся к нашему модулю config к созданной переменной TOKEN.
Сначала нашего пользователя надо поприветствовать и коротко объяснить суть происходящего. Для этого создал функцию send_welcome(message)
, которая будет вызываться тогда, когда пользователь первый раз зайдет в бота, ну либо введет команду /start
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 можно вставлять аудио, документы и прочие объекты тг. Функционал моего бота пока ограничится текстом.
Теперь мне надо мою идею из головы как-то конвертировать в алгоритм хотя бы на бумаге, а в итоге в код. Изначально пользователь видит приветствие бота, читает инструкцию и жмет на кнопку "Сделать заказ". Ему должно показаться меню с выбором категорий, типа
Особые напитки
Кофе
Горячие напитки
При выборе категории пользователю надо показать меню с перечнем конкретных товаров. К примеру, если это будут "Особые напитки", то надо отобразить:
Латте Лаванда Шалфей
Раф Лимонный Пай
Раф Шоколадный трюфель
Кедровый раф
Капучино Соленая карамель
Зеленый капучино
И так уже для каждой категории. При выборе конкретного товара надо отобразить доступные вариации, к примеру, если это напиток, то надо указать размер стаканчика, если это какой-нибудь сэндвич, то надо указать вариации, типа "с лососем" или "с курицей".
Честно говоря, на время написания этого куска статьи я уже попробовал одну реализацию бота, которая потерпела крах. Но я считаю нужным сначала рассказать о ней, чтобы вы смогли увидеть мою логику (или ее отсутствие), посмотреть на ошибки, как я постарался их исправить, а после пока то, что имею на данный момент.
Итак, я понял, что в итоге бот должен отрисовывать пользователю меню, получать какой-то текст, понимать, что требуется пользователь и отрисовывать следующее меню. И так до оплаты. Теперь меньше абстракции:
Этап - пользователь зашел в бота либо нажал
/start
Этап - выбирает категорию
Этап - выбирает конкретный продукт выбранной категории
Этап - Выбирает размер напитка/вид сэндвича и тп
Этап - по идее выбор добавляется в корзину (надо потом в БД создать отдельную переменную и сохранять туда), после чего можно предложить еще что-нибудь, циклично вернув на 2 этап, либо оплата
Оплата
В голову сразу идея приходит - для каждого этапа напишу свою функцию. В итоге файл 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.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()
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
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
по типу:
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
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 строке, где точно также происходит выбор товара.
И обратите внимание, что там реализация кнопки "Назад", которая работает адекватно.
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 = {
'Особые напитки': {
'Латте Лаванда Шалфей': {
'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.
# выше код...
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.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()
# ниже код...