Programming
December 22, 2018

Пишем Telegram бота на Python + хостинг на Heroku

Статья от автора канала @cozy_codespace

Приветствую вас, программисты! Сегодня я расскажу вам о том, как написать бота в Telegram с нуля и запустить его на бесплатной платформе по хостингу Heroku.

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


Что мы будем использовать?

  • Библиотеку python-telegram-bot (ссылка на GitHub репозиторий) с помощью которой мы сможем использовать Telegram Bot API
  • Visual Studio Code(VS Code) в качестве текстового редактора, где мы будем писать код
  • Для хостинга сервис - heroku.com. Вам нужно будет создать там учётную запись
  • Интерпретатор Python, который вы можете скачать здесь
  • Аккаунт в Telegram, для того, чтобы создать бота и тестировать его

ОЧЕНЬ ВАЖНО! В этом туториале я использую операционную систему Windows, поэтому некоторые моменты могут отличаться, в зависимости от вашей ОС.


Что будет делать бот?

Бот, которого мы будем писать будет помогать пользователям с изучением академических слов из теста SAT. Он будет иметь две функции:

  • Отправлять случайные слова из списка из 262 академических слов
  • Отправлять вопросы и варианты ответа для того, чтобы пользователь смог проверить своё знание академических слов

Этот бот будет полезен подписчикам моего телеграм канала @satprepare.


Этап 1. Подготовка к написанию бота

Для начала нам необходимо создать директорию(папку) с нашим проектом. В моём случае она называется SATVocabularyBot и находится на рабочем столе. Поэтому её расположение следующее: C:\Users\HP\Desktop\SATVocabularyBot

Далее заходим в эту папку в Visual Studio Code через File - Open Folder. После этого давайте сразу создадим Python файл с расширением .py в нашем проекте и назовём его main.py. Он у меня уже есть, но выглядеть это будет примерно так:

На этом же скриншоте под окном вы можете увидеть открытый терминал. Вам нужно тоже его открыть через View - Terminal. Далее, через этот терминал вам необходимо установить библиотеку python-telegram-bot. Сделать это можно написав:

pip install python-telegram-bot

Это будет выглядеть так:

И заметьте, что я использую тот интерпретатор Python, который установил сам, а не тот который предлагает VS Code. Это помогло мне избежать кучу ошибок при установке библиотеки.

Лучше использовать Python версии 3.7.0 и выше!

После того, как вы нажмёте Enter, у вас установится библиотека и мы сможем начать писать код.

Но, как писать бота, которого у нас нет?


Этап 2. Создание бота

Теперь нам необходимо создать самого бота в Telegram. Поэтому заходим в Telegram и открываем переписку с "ботом для создания ботов" - @BotFather.

На скриншоте я скрыл от вас TOKEN бота, так как имея доступ к нему можно делать с ботом всё, что угодно. Поэтому и вы никому не показывайте токен своего бота, пока что просто скопируйте его.

Теперь у нас есть бот и можно начать его программировать. Кстати, в этом туториале я не буду показывать, как поставить аватарку или приветственный текст для вашего бота, так как вы можете сделать это сами с помощью команды Edit Bot у @BotFather.


Этап 3. Написание бота

На этом этапе мы уже начнём писать самого бота, то есть его функционал.

В файле main.py пишем следующее:

import logging
import telegram
from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, ConversationHandler, RegexHandler

logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)

def main():
    updater = Updater(token='Токен вашего бота')

    dispatcher = updater.dispatcher

    conv_handler = ConversationHandler(
        entry_points = [CommandHandler('start', start)],

        states = {
            ACTION: [RegexHandler('^(Learn new words|Check yourself)#39;, action)],
            ANSWER: [MessageHandler(Filters.text, answer_check)]
        },

        fallbacks=[CommandHandler('cancel', cancel)]
    )

    dispatcher.add_handler(conv_handler)

    dispatcher.add_error_handler(error)
    
    updater.start_polling()
    updater.idle()

if __name__ == '__main__':
    main()

Давайте разберёмся с тем, что это значит.

В самом начале мы импортируем различные библиотеки:

  • logging - это библиотека для логирования, с помощью которой мы создадим логгер и сможем выявлять ошибки в коде нашего бота.
  • telegram и telegram.ext - библиотека для работы с ботом Telegram, которую мы установили в первом этапе нашего туториала.

Далее, мы создаём логгер, который будет выводить ошибки в терминале.

После этого мы создаём метод main, который будет всё время вызываться первым благодаря этим строчкам кода, которые мы записали в конце предыдущего блока кода:

if __name__ == '__main__':
    main()

Коротко об объектах и обработчиках

В самом же методе main мы создаём объекты:

  • updater - с его помощью мы соединимся нашим ботом в телеграме, поэтому в одинарные кавычки вам нужно скопировать и вставить токен вашего бота.
  • dispatcher - все обновления будут идти через него, то есть он будет отвечать за обновления в непосредственно самом Telegram, текстовые команды и обработчики событий(handlers).
  • conv_handler - является объектом Conversation Handler, который является сложным обработчиком событий. Он включает в себя 4 разных коллекций обработчиков событий: список entry_points, словарь states, список fallbacks и список timed_out_behavior. В нашем боте мы будем использовать только первые три.

Если говорить подробнее о коллекциях обработчиков, то можно сказать, что entry_points используется для того, чтобы начать переписку с ботом. Поэтому мы вносим в неё CommandHandler - обработчик команд на команду 'start'. Таким образом, когда пользователь напишет '/start' боту, у нас вызовется метод start, который мы напишем чуть позже.

States может содержать несколько обработчиков событий, которые отвечают за различные состояния переписки. Например, в нашем случае мы имеем два состояния: ACTION и ANSWER. Первое отвечает за то, какое действие выберет пользователь, а второе за то, какой ответ напишет пользователь при выполнении теста. RegexHandler в первом состоянии обрабатывает строки 'Learn new words' или 'Check yourself' и далее передаёт один из них в метод action. MessageHandler во втором состоянии обрабатывает любой текст, поэтому мы написали Filters.text, но он также может обрабатывать и другие типы сообщений. В конце статьи я дам ссылку на документацию, где всё это есть.

Поэтому за пределами метода main(можно после создания логгера)пишем следующие строки кода:

ACTION, ANSWER = range(2)

Fallbacks используется для того, чтобы выйти из текущего состояния, поэтому при нажатии на '/cancel' пользователь сможет выйти на предыдущее состояние.

Далее, через эти строки мы добавляем обработчик событий conv_handler и error_handler, отвечающий за ошибки в dispatcher:

dispatcher.add_handler(conv_handler)
dispatcher.add_error_handler(error)

Следующие строки кода начинают принимать обновления с нашего Telegram бота:

updater.start_polling()
updater.idle() #незн зачем это

Но я не совсем уверен в последнем (просто увидел, как это используется в примерах в официальном репозитории библиотеки)

Polling и Webhooks

Небольшое отступление от темы:

Наш бот использует polling, то есть периодически отправляет запросы на сервера Telegram. Можно использовать webhook-и, чтобы бот отправлял запросы на определённый url, но опыта работы с ними у меня еще нет, да и используются они только для крупных проектов, которые используются огромным количеством пользователей и там нужна производительность.

Продолжаем писать нашего бота...

Метод start

В первую очередь напишем метод (функцию) start, который, как я и говорил будет вызываться командой '/start':

def start(bot, update):
    bot.send_chat_action(chat_id=update.message.chat_id, action = telegram.ChatAction.TYPING)
    time.sleep(1)
    custom_keyboard = [['Learn new words'], ['Check yourself']]
    reply_markup = telegram.ReplyKeyboardMarkup(custom_keyboard, one_time_keyboard=False)
    bot.send_message(chat_id=update.message.chat_id, text="What do you want to do?", reply_markup=reply_markup)
    return ACTION

Здесь мы используем time, поэтому не забудьте импортировать библиотеку time в самом начале:

import time

А еще это:

from telegram import ChatAction

bot.send_chat_action - предназначен для того, чтобы бот показывал действие, как здесь:

time.sleep(1) - для того, чтобы он делал это на протяжении 1 секунды. Без этого вы не увидите этой надписи на скриншоте.

Далее, мы создаём кастомную клавиатуру (custom_keyboard), состоящую из двух кнопок на выбор.

reply_markup - это разметка, которая будет отправляться в ответ на сообщение пользователя. В неё мы добавляем нашу кастомную клавиатуру.

one_time_keyboard = False - означает то, что клавиатура не исчезнет после того, как пользователь отправит сообщение.

bot.send_message - для того, чтобы бот отправил соответствующее сообщение пользователю в чат. В нашем случае принимает параметры (может принимать больше):

  • chat_id - уникальный идентификатор чата в формате @username
  • text - определённый текст в одинарных кавычках
  • reply_markup - разметка, которую должен отправить бот, то есть клавиатуру

Последняя строка в методе отправляет состояние ACTION, которое далее перехватывает RegexHandler, о котором говорилось раньше.

Кстати, не забудьте добавить этот метод в код, так как возможно без него у вас не будет работать bot.send_action (взял этот код в Wiki Pages репозитория, поэтому понятия не имею, что он делает):

def send_action(action):
    def decorator(func):
        @wraps(func)
        def command_func(*args, **kwargs):
            bot, update = args
            bot.send_chat_action(chat_id=update.effective_message.chat_id, action=action)
            return func(bot, update, **kwargs)
        return command_func
    
    return decorator

Поэтому нужно еще импортировать следующее:

from functools import wraps

Файл dictionaries.py

Перед тем, как начать писать метод action необходимо создать 3 списка, которые будут источником информации для бота. Нужно создать список слов, описаний слов и типов слов, то есть words, description и type.

Я решил занести эти списки в новый файл dictionaries.py, чтобы эти данные не мешали в нашем главном файле. По скриншоту ниже вы поймёте почему😂:

Говнокод? Возможно. Просто мне лень париться насчёт различных баз данных для хранения всей информации.

Не забудьте написать это, чтобы мы смогли пользоваться этими списками в файле main.py:

import dictionaries

words = dictionaries.words
type = dictionaries.type
description = dictionaries.description

Методы action и learn

Следующий шаг - это написание метода action, который вызывается RegexHandler-ом. Вот как он будет выглядеть:

def action(bot, update):
    if(update.message.text == 'Learn new words'):
        learn(bot, update)
    elif(update.message.text == 'Check yourself'):
        num = generate_correct_answer()
        global correct_word 
        correct_word = num
        correct_num = random.randint(1, 4)
        first_incorrect = words[random.randint(1, len(words) - 1)]
        second_incorrect = words[random.randint(1, len(words) - 1)]
        third_incorrect = words[random.randint(1, len(words) - 1)]
        if(correct_num == 1):
            custom_keyboard=[[words[correct_word]], [first_incorrect], 
            [second_incorrect], [third_incorrect]]
        elif(correct_num == 2):
            custom_keyboard=[[first_incorrect], [words[correct_word]], 
            [second_incorrect], [third_incorrect]]
        elif(correct_num == 3):
            custom_keyboard=[[first_incorrect], [second_incorrect], 
            [words[correct_word]], [third_incorrect]]
        elif(correct_num == 4):
            custom_keyboard=[[first_incorrect], [second_incorrect], 
            [third_incorrect], [words[correct_word]]]
        reply_markup = telegram.ReplyKeyboardMarkup(custom_keyboard, one_time_keyboard=False)
        bot.send_message(chat_id=update.message.chat_id, text=description[correct_word], reply_markup=reply_markup)
        return ANSWER

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

def learn(bot, update):
    num = random.randint(1, len(words) - 1)
    bot.send_chat_action(chat_id=update.message.chat_id , action = telegram.ChatAction.TYPING)
    time.sleep(1)
    bot.send_message(chat_id=update.message.chat_id, text="*"+ words[num]+"* - "+description[num]+"\n"+"\n_"+type[num]+"_", parse_mode=telegram.ParseMode.MARKDOWN)
    bot.send_message(chat_id=update.message.chat_id, text="What is next?")

Здесь всё понятно - создаётся рандомное число и бот отправляет всю информацию из списка по индексу этого числа о нём в виде одного сообщения.

Рандом? Нужно импортировать следующее:

import random

parse_mode - нужен для того, чтобы бот превратил нужные слова в кавычках в bold или italic. Для этого используется Markdown mode.

Возвращаемся к методу action.

Здесь также создаётся случайное число через метод generate_correct_answer:

def generate_correct_answer():
    num = random.randint(1, len(words) - 1)
    return num

Также в этом методе будет использоваться глобальная переменная correct_word, поэтому добавьте её вне всех методов:

correct_word = 0

Для доступа к ней вне метода action я написал метод get_correct_word:

def get_correct_word():
    return correct_word

Далее в методе action мы создаём три неправильных случайных ответа и распределяем все ответы тоже в случайном порядке. Думаю здесь не нужно объяснений - всё и так предельно ясно.

Ну и в конце метода возвращается состояние ANSWER, которое перехватывается MessageHandler-ом, после чего вызывается метод answer_check.

Метод answer_check

Этот метод будет отвечать за то, чтобы проверять ответы введённые пользователем. Он выглядит так:

def answer_check(bot, update):
    correct_word = get_correct_word()
    custom_keyboard = [['Learn new words'], ['Check yourself']]
    reply_markup = telegram.ReplyKeyboardMarkup(custom_keyboard, one_time_keyboard=False)
    if(update.message.text == words[correct_word]):
        bot.send_chat_action(chat_id=update.message.chat_id , action = telegram.ChatAction.TYPING)
        bot.send_message(chat_id=update.message.chat_id, text="*Correct!*", parse_mode=telegram.ParseMode.MARKDOWN)
        bot.send_message(chat_id=update.message.chat_id, text="What do you want to do?", reply_markup=reply_markup)
        return ACTION
    else:
        bot.send_chat_action(chat_id=update.message.chat_id , action = telegram.ChatAction.TYPING)
        bot.send_message(chat_id=update.message.chat_id, text="*Incorrect!*" + " Correct answer is: " + words[correct_word], parse_mode=telegram.ParseMode.MARKDOWN)
        bot.send_message(chat_id=update.message.chat_id, text="What do you want to do?", reply_markup=reply_markup)
        return ACTION

Здесь есть два условия: если ответ правильный и если нет. В соответствии с этим будут отображаться соответствующие сообщения от бота.

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

Еще есть два метода cancel и error, которые вызываются из метода main и играют незначительную часть нашего кода (комментировать их не буду):

def cancel(bot, update):
    return ConversationHandler.END

def error(bot, update, error):
    logger.warning('Update "%s" caused error "%s"', update, error)

Итак, мы написали нашего бота но он работает только тогда, когда мы нажимаем на кнопку run. Но как сделать так, чтобы он работал постоянно?


Часть 4. Хостинг бота

Надеюсь вы уже создали аккаунт в Heroku. Всё, что вам требуется сейчас сделать это войти в свой аккаунт и увидеть этот экран:

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

Далее, вам необходимо скачать Heroku Command Line Interface (CLI) по этой ссылке и Git по этой ссылке.

После того, как вы всё установили, перейдите в терминал в VS Code. Напишите туда следующее:

heroku login

Нажмите на любую кнопку (кроме q) и вас перекинет в браузер, где вам нужно нажать на кнопку Log in:

Далее можно вернуться обратно в VS Code. Перед тем, как загрузить нашего бота на Heroku, нам следует добавить еще два файла: requirements.txt и Procfile. Заметьте, что у Procfile нет никакого расширения.

В Procfile напишем эту строку:

worker: python main.py $PORT

Это означает, что тип нашего dyno (так называется что-то на подобии сервера в Heroku) - worker будет работать с файлом main.py. Он будет работать всегда и без передышки. Web dyno будет иногда выключаться, но нам этого не надо.

В файл requirements.txt пишет эти строки:

appdirs==1.4.3
certifi==2018.1.18
Cython==0.23
Django==1.10.6
docutils==0.13.1
packaging==16.8
pipenv==11.8.0
psutil==5.0.1
pyowm==2.8.0
Pygments==2.2.0
pyparsing==2.2.0
pyTelegramBotAPI==3.6.1
python-telegram-bot==7.0.1
requests==2.13.0
six==1.10.0
virtualenv==15.1.0
virtualenv-clone==0.3.0

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

Теперь у нас всё готово для загрузки бота на Heroku!

Возвращаемся в терминал, где мы залогинились на Heroku, помните? Пишем туда:

heroku create

После этого у нас создаётся проект на Heroku. Зайдите на сайт и посмотрите его имя. Например, у меня создался проект и он называется agile-refuge-53805.

Далее, в терминале следует написать:

git add .

Потом делаете свой первый коммит:

git commit -am "make it better"

И затем делаете деплой своего кода на Heroku:

git push heroku master

Вам нужно будет подождать немного (обычно от 1 до 3 минут) и потом написать следующее в терминал, чтобы запустить вашего worker dyno:

heroku ps:scale worker=1

Готово! Проверьте своего бота - теперь он должен работать постоянно.

Кстати, Heroku не полностью бесплатный сервис, поэтому иногда они будут присылать вам сообщения о том, что ваши dyno перестанут работать надо заплатить. Я обычно игнорю такие сообщения, а боты работают как ни в чем не бывало :)


Я надеюсь вам понравился мой туториал и вы написали своего бота в Telegram! Если вам понравилась статья, то не поленитесь поставить лайк или написать мне "Спасибо!". Это будет мотивировать меня писать для вас статьи!

Если вы немного запутались, то вот GitHub репозиторий на этого бота.

Ссылка на официальную документацию

Мой GitHub

Есть вопросы? Пиши @coders_assistant_bot.