Creating a Python Telegram bot in PyCharm

In this blog post I’ll cover the initial PyCharm setup and then I’ll show you how to create a Telegram bot using Python. Finally, we will add some validation to our application because everyone knows that’s a Good Thing to do. I have recently started learning Python using PyCharm so if you have any improvements or comments, please leave me a message at the end of this blog post!

Credit: This blog post is based off this excellent post; I’ve made some modifications to it, added PyCharm specific content as well as plenty of validation as you’ll soon see.

Python

Before we start, you need to install a recent version of Python on your machine. At the time of writing, that was 3.11.2. A version of Python may have shipped with your operating system, but it might well need upgrading. 

If you’re using macOS and Homebrew you can use brew install pyenv. Alternatively, this page covers installing Homebrew and then Python. If you’re using Windows, this page is where you want to go. Ensure you know which path you installed Python into because PyCharm will need to know about it in the next step.

Telegram

Of course downloading Telegram would be a good idea if you’re following this tutorial because you’ll want to check that your code works! You will also need to get a bot code from the platform. Once it’s installed, search for the amusingly named user @BotFather and send them a /newbot message. 

Now you can follow the prompts to name your bot. I named mine horoscopefinder. You’ll also need to choose a unique username and then @BotFather will give you a magic token which you will need in this tutorial.

PyCharm

You can get PyCharm from JetBrains and use the free Community Edition for this application. Of course, if you have PyCharm Professional, you can also use that. If you’re already using any JetBrains product (and I sure hope you are), get the JetBrains Toolbox app to manage all your tools and their updates in one place. Also, check out this video from Paul if you want to know more about installing PyCharm.

The Bot Code

Open up PyCharm and click New Project to launch the wizard.

Stick with the default location path unless you have a good reason to change it, then give your project a name such as horoscope-bot.

Python interpreters are usually a per-project basis and they are isolated from your other projects. We will create a new virtual one so leave the default setting of Virtualenv and the default location. In the Base interpreter drop-down you should see the version of Python that you’ve installed, in our case, that’s 3.9. Use the file browser button to locate the version of Python that you’ve installed. Leave the other defaults and click Create.

Now we have a little main.py file which PyCharm created for us which is helpful if you’ve not used the IDE before. For this tutorial, we’re going to delete all of that and start fresh.

Delete the contents of the main.py file and type the following code:

import os 
from telebot import TeleBot 

BOT_TOKEN = os.environ.get('BOT_TOKEN') bot = TeleBot(BOT_TOKEN)

Both telebot and Telebot will be underlined with a red squiggly line. You can fix that by putting your caret in telebot and using (⌥⏎ | Alt+Enter) then selecting Install package telebot.

Remember that Telegram token you got from @BotFather? Now’s the time to use it. You can either add it directly into your Telebot method with single quote marks:

BOT_TOKEN = os.environ.get('BOT_TOKEN')
bot = TeleBot('6223382617:ADF_Ji_W60L9PCKE_r0g4FGdDKg9Amf_pKl')

Or, my preferred way is to add it as an environmental variable in the Run Configuration. To do that, head up to the Current File drop-down on the top right and select Edit Configurations. You need to create a new Run Configuration for your file:

Edit configurations selection in the Run Configurations menu

Click Add New Run Configuration and search for Python in the list. Name your run configuration main, change your script path to point to your main.py file and then add your BOT_TOKEN after as an environment variable after the existing one. It should look like this (but with your path and your BOT_TOKEN):

Click OK to save your Run Configuration before we go back to editing the code.

Let’s start to build our bot up now. Add the following code:

@bot.message_handler(commands=['start', 'hello'])
def send_welcome(message):
    bot.reply_to(message, "Howdy, how are you doing?")

Now we have a way to respond when someone types in either “start” or “hello” and we also reply to them. 

We really ought to test it at this point so add this line of code to ensure the bot will keep listening to us:

bot.infinity_polling()

An important note here! The above line needs to be at, and remain at, the bottom of your file and not indented. I’ll remind you later in case you forget too.

Pause and Test!

You can run your application with the Run Configuration you created by taking your mouse up to the main (the name of your file) and clicking the run arrow to the right. Alternatively you can right click in your file and select Run ‘main’.

Run main.py button

Now pick up your phone, open Telegram and search for your bot name such as horoscopefinder (my bot). Now type in /hello.

Telegram should respond with “Well hello there! How are you doing?”. Quick cross-check, if it’s not working, check that you have put your BOT token in correctly. Ensure it’s in single quotes if you put it in the Telebot argument and ensure there’s a semi-colon separating arguments if you put it in the run configuration as an argument.

Your code at this stage should look like this (assuming you used a Run Configuration in PyCharm):

import os
from telebot import TeleBot

BOT_TOKEN = os.environ.get('BOT_TOKEN')
bot = TeleBot(BOT_TOKEN)

@bot.message_handler(commands=['start', 'hello'])
def send_welcome(message):
    bot.reply_to(message, "Howdy, how are you doing?")

bot.infinity_polling()

Building up the Bot

So what’s next? Let’s add some code to call the API we’re using in this tutorial:

def get_daily_horoscope(sign: str, day: str) -> dict: 
"""Get daily horoscope for a zodiac sign.
Keyword arguments:
sign:str - Zodiac sign
day:str - Date in format (YYYY-MM-DD) OR TODAY OR TOMORROW OR YESTERDAY
Return:dict - JSON data """
url = "https://horoscope-app-api.vercel.app/api/v1/get-horoscope/daily"
params = {"sign": sign, "day": day}
response = requests.get(url, params)
return response.json()

You’ll notice that requests has a red squiggly line under it. Pop your caret in the code and use (⌥⏎ | Alt+Enter). We want to import requests. PyCharm will add the import for you.

Alt Enter and then Import Requests

At this stage, you may also have a squiggly line under the line that starts with def. If you do it’s likely an indicator that your file isn’t formatted properly according to Python’s standards. When you hover your mouse over it you’ll get a popup about PEP 8 standards. You can either use (⌥⏎ | Alt+Enter) and select Reformat this file or Reformat the file (⌥⌘L | Ctrl+Alt+L) to banish the squigglies from your code. If you have more red in your code at this stage, check you have indented your function as I’ve shown above, sometimes indentation can get lost if you copy and paste it.

Sending out bot /hello and /start is cute, but we want to trigger a workflow when a user interacts with our bot and types in /horoscope so add the following code:

@bot.message_handler(commands=['horoscope'])
def sign_handler(message):
user_input = "What's your zodiac sign?\nChoose one: *Aries*, *Taurus*, *Gemini*, *Cancer,* *Leo*, *Virgo*, *Libra*, *Scorpio*, *Sagittarius*, *Capricorn*, *Aquarius*, and *Pisces*."
sent_msg = bot.send_message(message.chat.id, user_input, parse_mode="Markdown")
bot.register_next_step_handler(sent_msg, day_handler)

This tells the bot that when it receives /horoscope it needs to ask us what are star sign is before calling register_next_step_handler for the bot. You’ll notice that it takes two arguments and right now, our application has no idea what day_handler is. Let’s create that function next:

def day_handler(message):
    star_signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
    text = "What day do you want to know?\nChoose one: *TODAY*, *TOMORROW*, *YESTERDAY*, or a date in format YYYY-MM-DD *up to a year ago*."
    sent_msg = bot.send_message(message.chat.id, text, parse_mode="Markdown")
    bot.register_next_step_handler(sent_msg, fetch_horoscope, star_signs)

Now we need to create the function for fetch_horoscope. Add the following code:

def fetch_horoscope(message, sign):
    day = message.text
    get_horoscope_data(day, message, sign)

Last but not least, let’s add the function for get_horoscope_data so add the following code:

def get_horoscope_data(day, message, sign):
    horoscope = get_daily_horoscope(sign, day)
    data = horoscope["data"]
    horoscope_message = f'*Horoscope details for {sign}, {data["date"]}*: {data["horoscope_data"]}'
    bot.send_message(message.chat.id, "Here's your horoscope!")
    bot.send_message(message.chat.id, horoscope_message, parse_mode="Markdown")

It’s testing time!

Before you fire up your application, two very important notes:

  • Make sure you stop it if it’s still running from last time. You can do this with the red square next to main in the top-right of PyCharm in your Run Configuration again. 
  • Make sure the line bot.infinity_polling() that we added at the start is at the end of the file and not indented.

Send /horoscope to your bot and see what happens! The code for this snapshot in time is on GitHub in case you get stuck or something has gone wrong.

Adding a requirements.txt file

It’s all well and good having the three imports at the top of the file, but if you want anyone else to be able to use this code, it’s best practice to add a requirements.txt file.

Open up the Project tool window if it’s not already open with (⌘1 | Alt+1), and in your root directory do (⌘N | Alt+Ins) then select New File and call it requirements.txt.

Head back to your main.py file. You can do that with (⌘[ | Ctrl+Left Arrow), or use Recent Files (⌘E | Ctrl+E). You’ll see that the squiggly lines are back under requests and telebot. Use (⌥⏎ | Alt+Enter) on one of them and select Add imported packages to requirements. PyCharm will add both packages to your requirements.txt file so now other users of your code will know that they need those libraries to run your code. There’s no squiggly line under os because that import is part of Python already, so anyone who wants to use your code won’t need to download that package separately.

Check everything still runs before we move on. Again there’s a snapshot in GitHub.

Tidying up that string

Time to do some small refactoring. You’ve probably noticed that we’re using the same string of star signs twice, so let’s deal with that to start with. In your day_handler function, click on the star_signs array and then do (⌃T | Ctrl+Alt+Shift+T) and select 4 Introduce constant. I called mine STAR_SIGNS. Now delete that line because you can just call the constant in this method:

bot.register_next_step_handler(sent_msg, fetch_horoscope, STAR_SIGNS)

We can also use the same constant in our sign_handler function by making it an f-string. That is a string that also accepts expressions. Take this string:

user_input = "What's your zodiac sign?\nChoose one: *Aries*, *Taurus*, *Gemini*, *Cancer,* *Leo*, *Virgo*, *Libra*, *Scorpio*, *Sagittarius*, *Capricorn*, *Aquarius*, and *Pisces*."

And rewrite it to this:

user_input = f"What's your zodiac sign?\nChoose one: {STAR_SIGNS}."\

This is a good time to mention the f-string in Python which we’ve used above. The f stands for “formatted”; this article is helpful for learning more about f-strings. To create one you just use the f character before you start your string then you can use curly brackets to denote the function, in this case, it’s our string constant.

Fix a little bug

If you’ve been playing around with your horoscope bot you’ve probably noticed two things. Firstly that it’s very easy to break it – we will fix that up shortly by adding some validation. Secondly, you may have noticed that the string it returns when you ask for your horoscope is incorrect. The bot is returning:

Horoscope details for [‘Aries’, ‘Taurus’, ‘Gemini’, ‘Cancer’, ‘Leo’, ‘Virgo’, ‘Libra’, ‘Scorpio’, ‘Sagittarius’, ‘Capricorn’, ‘Aquarius’, ‘Pisces’], Mar 1, 2023: Today <some horoscope text here>

When it should be returning something like:

Horoscope details for Aries Mar 1, 2023: Today <some horoscope text here>

Let’s fix that now. The function that controls this is get_horoscope_data. The string it’s using is:

horoscope_message = f'*Horoscope details for {sign}, {data["date"]}*: {data["horoscope_data"]}'

It seems okay, but take a closer look. The {sign} function appears to be the full array of strings, whoops!

Let’s revisit our day_handler function. The problem lies in this line:

bot.register_next_step_handler(sent_msg, fetch_horoscope, STAR_SIGNS)

We’re passing the whole string array through (STAR_SIGNS) rather than parsing it at this stage and converting the user input into text. First, let’s fix that and add a new line above that says:

sign = message.text

Now instead of passing our constant STAR_SIGNS in, let’s pass in sign. We can also delete:

star_signs = STAR_SIGNS

The final code should look like this:

def day_handler(message):
    sign = message.text
    text = "What day do you want to know?\nChoose one: *TODAY*, *TOMORROW*, *YESTERDAY*, or a date in format " \
           "YYYY-MM-DD *up to a year ago*."
    sent_msg = bot.send_message(message.chat.id, text, parse_mode="Markdown")
    bot.register_next_step_handler(sent_msg, fetch_horoscope, sign)

Now would be an excellent time to test your bot again! The snapshot of the code is on GitHub for you.

Validation, we need some!

As I mentioned earlier, if you’ve been playing with your horoscope bot and potentially doing anything other than handling it very gently, it’s probably broken on you at least once. It would be good to make it a little more sturdy, and to do that we need to add some validation to our user inputs. Remember, our user inputs are our star sign and the date.

Validating the star sign

This is arguably the easiest validation to manage because we just need to check that it’s a string and that it’s one of the strings we allow. We can use the day_handler function to manage this.

We already know what the valid star signs are because we’ve stored them in the constant STAR_SIGNS. We can check if what the user typed is in our constant with an if statement, but what do we do if it’s invalid? In that case, we first craft a sassy response to the user. Then we pass that response to the bot and send the application back to asking for their star sign.

def day_handler(message):
    sign = message.text
    if sign in STAR_SIGNS:
        text = "What day do you want to know?\nChoose one: *TODAY*, *TOMORROW*, *YESTERDAY*, or a date in format " \
           "YYYY-MM-DD *up to a year ago*."
        sent_msg = bot.send_message(message.chat.id, text, parse_mode="Markdown")
        bot.register_next_step_handler(sent_msg, fetch_horoscope, sign)
    else:
        not_valid = "That was not a valid star sign, try again!"
        bot.send_message(message.chat.id, not_valid)
        sign_handler(message)

Pause here and test that out, try entering all sorts of junk for the star sign. Does something still break? If you tried entering your star sign in anything other than exactly how it’s spelt out in the STAR_SIGNS constant then yes, it will still break! Let’s fix that next.

Locate this line in your day_handler function:

sign = message.text

and change it to:

sign = message.text.title()

Now stop and restart your bot and test it out; problem solved! The title method just converts the user input to the format that we’re expecting.

Validating the date

This is a slightly juicier challenge because the date can either be a string (TODAY, TOMORROW, YESTERDAY) or a date in the format YYYY-MM-DD. First, we need to check what kind of input the user has given us, a string or a date. We will manage this validation inside our fetch_horoscope function.

First, let’s validate if it’s a day or a date; add the following code below the existing code:

valid_days = ["Today", "Tomorrow", "Yesterday"]
if day in valid_days:
    get_horoscope_data(day, message, sign)
else:

You will get an error because we’ve not yet crafted the else portion of the validation. But what we’ve done here is checked if it’s a string that is one of our valid days. If it is, call get_horoscope_data and pass it through. However, if it’s not. it gets fun because we know it’s not a valid string, but it could be a date. And, if it’s a date, we need to check that it is:

  • In the range of the API (which means less than 365 days in this case and not in the future)
  • Of the right format

Let’s do this using a try and except statement to allow us to deal with the error that the program will throw if it gets to the end of the validation and the user has put in garbage.

Add the following code to the else portion of your code block:

else:
    # Validate that it's a date in the right format
    try:
        date_object = datetime.strptime(day, '%Y-%m-%d')
        # Validate if it's in the prior year
        now = str(datetime.now())
        if day < now:
            earliest_valid_date = datetime.now() - timedelta(days=365)
            # Validate if the date is in the future
            if date_object >= earliest_valid_date:
                get_horoscope_data(day, message, sign)
            else:
                date_too_old = "The date is too far in the past for my tiny brain, try again"
                bot.send_message(message.chat.id, date_too_old)
        else:
            not_valid = "Date is in the future, try again."
            bot.send_message(message.chat.id, not_valid)
    except ValueError:
        invalid_format = "Incorrect data format, should be YYYY-MM-DD"
        bot.send_message(message.chat.id, invalid_format)

You’ll notice that you once again have red squiggles in your code. Use (⌥⏎ | Alt+Enter)  on datetime and select Import this name then datetime.datetime.date

Use (⌥⏎ | Alt+Enter) and select Import timedelta(SupportAbs) from datetime. You’ll notice that PyCharm has added an additional import statement at the top of your class:

from datetime import datetime, timedelta

First, we validate that the date (if it even is a date) is of the correct format; if it’s not, the code will drop through to the except portion of the statement and dish out the error to the user.

Next, we validate if the date is within the range we expect. To do that, we first need to know where we are in time and space, the now variable. Again if we hit either of these nested else statements, we notify the user and then drop through to the except statement.

Time to test your bot again! This time try and break it, channel your inner tester and test your validation. Some corner cases have still not been handled, but I’ll leave those with you! The code at this point is on GitHub, as always.

Refactor to improve readability

You’ve probably noticed that our fetch_horoscope function is a bit ugly and long now. We can do a quick refactor to dramatically improve the readability of our code by extracting the date validation into a function. Select the following code:

if day < now:
    earliest_valid_date = datetime.now() - timedelta(days=365)
    # Validate if the date is in the future
    if date_object >= earliest_valid_date:
        get_horoscope_data(day, message, sign)
    else:
        date_too_old = "The date is too far in the past for my tiny brain, try again"
        bot.send_message(message.chat.id, date_too_old)
else:
    not_valid = "Date is in the future, try again."
    bot.send_message(message.chat.id, not_valid)

Use (⌃T | Ctrl+Alt+Shift+T) and select option 6 Extract method. Type in a name for the new method, such as date_validation, accept the defaults and let PyCharm extract that validation to its own function to tidy things up. And there we go. This code is also on GitHub.

A final bug

I managed to introduce a bug while I was creating this tutorial! This bug isn’t in the code on GitHub because I had commented the line out but if you have been pasting the code snippets in then you probably have it too. 

Test your bot with a date in the future and see that it tells you that the date is in the future but it also prints out an erroneous horoscope message for today’s date. 

Check your fetch_horoscope function and remove the first instance of this call:

get_horoscope_data(day, message, sign)

That line should only be called in the if statement if the day is one of our valid days. And this is why you should delete commented out code!

Phew! We got there 🙂

Here’s the final code in case you need to compare yours, but I hope yours works!