We write a Telegram current weather bot by IP address in Python aiogram

Shtosh. In this article, I will tell you how to create a Telegram bot that receives the current weather by IP address. We will use the Python language and an asynchronous library to interact with the Telegram Bot API – aiogram.

So how can you create such a bot?

TL;DR

Clone the repository shtosh-weather-bot and follow the instructions in the README.

Choosing a weather service with a free API

We need to take data about the current weather from somewhere. I also wish it was free. Site openweathermap we have what we need Current weather API. You can send 1000 requests per day for free.

https://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={API key}

By the way, if you are looking for some kind of application user interface for your project, I recommend the repository public-apis.

So, for the request, you need coordinates and a special key, which can be obtained by registering an account. Well, this is not a problem at all, you can register on temporary mail. Of course, if you are going to seriously use the API and buy more than 1000 requests per day, it is better to register an account on your mail. Captain obvious.

We go to My API keys and see the same key here. You can take mine, I don’t mind.

So let’s make a request. I chose the coordinates of New York, simply because I want and I can.

https://api.openweathermap.org/data/2.5/weather?lat=40.7143&lon=-74.006&appid=8537d9ef6386cb97156fd47d832f479c

This is the json we get.
{
  "coord": {
    "lon": -74.006,
    "lat": 40.7143
  },
  "weather": [
    {
      "id": 501,
      "main": "Rain",
      "description": "moderate rain",
      "icon": "10d"
    }
  ],
  "base": "stations",
  "main": {
    "temp": 297.11,
    "feels_like": 297.75,
    "temp_min": 295.11,
    "temp_max": 298.79,
    "pressure": 1013,
    "humidity": 84
  },
  "visibility": 10000,
  "wind": {
    "speed": 5.81,
    "deg": 123,
    "gust": 6.71
  },
  "rain": {
    "1h": 2.83
  },
  "clouds": {
    "all": 100
  },
  "dt": 1661183439,
  "sys": {
    "type": 2,
    "id": 2039034,
    "country": "US",
    "sunrise": 1661163203,
    "sunset": 1661211890
  },
  "timezone": -14400,
  "id": 5128581,
  "name": "New York",
  "cod": 200
}

Create a bot and install everything you need

Create a Telegram bot with Bot Father and take his token.

From the title of the video, you might have guessed that we will use the Python language and the library aiogram. I hope with installing Python you won’t have any problems. With aiogram too.

pip install aiogram

Lyrical digression

I borrowed a lot from project Alexey Goloburdin – author of the YouTube channel “Digitalize!” The problem is that his project is only for macOS devices, because the coordinates are taken using a command line tool whereami. Sample output:

Latitude: 45.424807, 
Longitude: -75.699234
Accuracy (m): 65.000000
Timestamp: 2019-09-28, 12:40:20 PM EDT

Also, his script simply outputs all the formatted information to the terminal, I would like to have a nicer and more convenient interface.

I decided that it was possible to refine the idea and cover the maximum number of users, neglecting the accuracy of the information.

We write code. The configuration file

So the file config.py contains constants:

  • Bot token BOT_API_TOKEN

  • OpenWeather key WEATHER_API_KEY

  • Request current weather CURRENT_WEATHER_API_CALL

config.py
BOT_API_TOKEN = ''
WEATHER_API_KEY = ''

CURRENT_WEATHER_API_CALL = (
        'https://api.openweathermap.org/data/2.5/weather?'
        'lat={latitude}&lon={longitude}&'
        'appid=' + WEATHER_API_KEY + '&units=metric'
)

Of course, data such as tokens and keys need to be stored in environment variables, but this is a pet project, I won’t deploy it, so I don’t bother too much.

Getting the coordinates

To get the coordinates, I created a separate module. Dataclass Coordinates contains latitude and longitude with float types.

from dataclasses import dataclass

@dataclass(slots=True, frozen=True)
class Coordinates:
    latitude: float
    longitude: float

You can find them by IP address using ipinfo.io/json. Here comes the answer.

{
  "ip": "228.228.228.228",
  "city": "Moscow",
  "region": "Moscow",
  "country": "RU",
  "loc": "55.7522,37.6156",
  "org": "Starlink",
  "postal": "101000",
  "timezone": "Europe/Moscow",
  "readme": "https://ipinfo.io/missingauth"
}

We are interested in the key "loc" short for location. Again the captain is obvious. Making a request using a function urlopen module request libraries urllib. Returning a dictionary with json.load()

from urllib.request import urlopen
import json

def _get_ip_data() -> dict:
    url="http://ipinfo.io/json"
    response = urlopen(url)
    return json.load(response)

In the function of obtaining coordinates, we parse this dictionary and return the dataclass of coordinates.

def get_coordinates() -> Coordinates:
    """Returns current coordinates using IP address"""
    data = _get_ip_data()
    latitude = data['loc'].split(',')[0]
    longitude = data['loc'].split(',')[1]

    return Coordinates(latitude=latitude, longitude=longitude)
coordinates.py
from urllib.request import urlopen
from dataclasses import dataclass
import json


@dataclass(slots=True, frozen=True)
class Coordinates:
    latitude: float
    longitude: float


def get_coordinates() -> Coordinates:
    """Returns current coordinates using IP address"""
    data = _get_ip_data()
    latitude = data['loc'].split(',')[0]
    longitude = data['loc'].split(',')[1]

    return Coordinates(latitude=latitude, longitude=longitude)


def _get_ip_data() -> dict:
    url="http://ipinfo.io/json"
    response = urlopen(url)
    return json.load(response)

Parsing OpenWeather API response

Next, consider the module api_service. All the fuss with the weather happens in it. The temperature is measured in degrees Celsius, which corresponds to the alias float numbers.

from typing import TypeAlias

Celsius: TypeAlias = float

As you know, Fahrenheit was created only so that Ray Bradbury could beautifully name his dystopia.

In the API response, the wind direction is given in degrees. I decided to bring them into a more convenient format. To do this, I created an enumeration of the main wind directions.

from enum import IntEnum

class WindDirection(IntEnum):
    North = 0
    Northeast = 45
    East = 90
    Southeast = 135
    South = 180
    Southwest = 225
    West = 270
    Northwest = 315

In the parsing function, rounding to 45 degrees looks like this: divide the degrees by 45, round and multiply back by 45. The result can be rounded up to 360 degrees, so we handle this case.

def _parse_wind_direction(openweather_dict: dict) -> str:
    degrees = openweather_dict['wind']['deg']
    degrees = round(degrees / 45) * 45
    if degrees == 360:
        degrees = 0
    return WindDirection(degrees).name

All weather data will be stored in a dataclass. Optionally, you can add here the rest of the information from the OpenWeather answer, such as atmospheric pressure, time zone, minimum and maximum temperatures currently recorded.

@dataclass(slots=True, frozen=True)
class Weather:
    location: str
    temperature: Celsius
    temperature_feeling: Celsius
    description: str
    wind_speed: float
    wind_direction: str
    sunrise: datetime
    sunset: datetime

Otherwise, nothing interesting happens in the module, just json parsing.

api_service.py
from typing import Literal, TypeAlias
from urllib.request import urlopen
from dataclasses import dataclass
from datetime import datetime
from enum import IntEnum
import json

from coordinates import Coordinates
import config

Celsius: TypeAlias = float


class WindDirection(IntEnum):
    North = 0
    Northeast = 45
    East = 90
    Southeast = 135
    South = 180
    Southwest = 225
    West = 270
    Northwest = 315


@dataclass(slots=True, frozen=True)
class Weather:
    location: str
    temperature: Celsius
    temperature_feeling: Celsius
    description: str
    wind_speed: float
    wind_direction: str
    sunrise: datetime
    sunset: datetime


def get_weather(coordinates=Coordinates) -> Weather:
    """Requests the weather in OpenWeather API and returns it"""
    openweather_response = _get_openweather_response(
        longitude=coordinates.longitude, latitude=coordinates.latitude
    )
    weather = _parse_openweather_response(openweather_response)
    return weather


def _get_openweather_response(latitude: float, longitude: float) -> str:
    url = config.CURRENT_WEATHER_API_CALL.format(latitude=latitude, longitude=longitude)
    return urlopen(url).read()


def _parse_openweather_response(openweather_response: str) -> Weather:
    openweather_dict = json.loads(openweather_response)
    return Weather(
        location=_parse_location(openweather_dict),
        temperature=_parse_temperature(openweather_dict),
        temperature_feeling=_parse_temperature_feeling(openweather_dict),
        description=_parse_description(openweather_dict),
        sunrise=_parse_sun_time(openweather_dict, 'sunrise'),
        sunset=_parse_sun_time(openweather_dict, 'sunset'),
        wind_speed=_parse_wind_speed(openweather_dict),
        wind_direction=_parse_wind_direction(openweather_dict)
    )


def _parse_location(openweather_dict: dict) -> str:
    return openweather_dict['name']


def _parse_temperature(openweather_dict: dict) -> Celsius:
    return openweather_dict['main']['temp']


def _parse_temperature_feeling(openweather_dict: dict) -> Celsius:
    return openweather_dict['main']['feels_like']


def _parse_description(openweather_dict) -> str:
    return str(openweather_dict['weather'][0]['description']).capitalize()


def _parse_sun_time(openweather_dict: dict, time: Literal["sunrise", "sunset"]) -> datetime:
    return datetime.fromtimestamp(openweather_dict['sys'][time])


def _parse_wind_speed(openweather_dict: dict) -> float:
    return openweather_dict['wind']['speed']


def _parse_wind_direction(openweather_dict: dict) -> str:
    degrees = openweather_dict['wind']['deg']
    degrees = round(degrees / 45) * 45
    if degrees == 360:
        degrees = 0
    return WindDirection(degrees).name

Making messages for the bot

The messages module contains messages for the bot by commands. weather report /weather contains a location, a description of the weather, temperature and how it feels.

from coordinates import get_coordinates
from api_service import get_weather

def weather() -> str:
    """Returns a message about the temperature and weather description"""
    wthr = get_weather(get_coordinates())
    return f'{wthr.location}, {wthr.description}\n' \
           f'Temperature is {wthr.temperature}°C, feels like {wthr.temperature_feeling}°C'

wind message /wind shows its direction and speed in meters per second.

def wind() -> str:
    """Returns a message about wind direction and speed"""
    wthr = get_weather(get_coordinates())
    return f'{wthr.wind_direction} wind {wthr.wind_speed} m/s'

Well, the message about the time of sunrise and sunset /sun_time. Here the datetime object is formatted into hours and minutes, the rest is irrelevant in this case.

def sun_time() -> str:
    """Returns a message about the time of sunrise and sunset"""
    wthr = get_weather(get_coordinates())
    return f'Sunrise: {wthr.sunrise.strftime("%H:%M")}\n' \
           f'Sunset: {wthr.sunset.strftime("%H:%M")}\n'

It should be noted that each time the function is called, a new API request is created. Why should this be noticed? Because at first I made a bot with one request and wondered why the information does not change over time. Because, ideally, make one request every 5 or 10 minutes, during which time the weather does not change much, and the OpenWeather data is also not updated every second.

messages.py
from coordinates import get_coordinates
from api_service import get_weather


def weather() -> str:
    """Returns a message about the temperature and weather description"""
    wthr = get_weather(get_coordinates())
    return f'{wthr.location}, {wthr.description}\n' \
           f'Temperature is {wthr.temperature}°C, feels like {wthr.temperature_feeling}°C'


def wind() -> str:
    """Returns a message about wind direction and speed"""
    wthr = get_weather(get_coordinates())
    return f'{wthr.wind_direction} wind {wthr.wind_speed} m/s'


def sun_time() -> str:
    """Returns a message about the time of sunrise and sunset"""
    wthr = get_weather(get_coordinates())
    return f'Sunrise: {wthr.sunrise.strftime("%H:%M")}\n' \
           f'Sunset: {wthr.sunset.strftime("%H:%M")}\n'

Inline keyboard

It was possible to make a reply keyboard, but I prefer Inline. 3 buttons for 3 commands.

from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup

BTN_WEATHER = InlineKeyboardButton('Weather', callback_data="weather")
BTN_WIND = InlineKeyboardButton('Wind', callback_data="wind")
BTN_SUN_TIME = InlineKeyboardButton('Sunrise and sunset', callback_data="sun_time")

4 keyboards for 4 commands, help command is added. What is the point? After the weather message, we don’t need to show its button. Same logic for all other commands except help. For it, the buttons of all 3 commands are displayed.

WEATHER = InlineKeyboardMarkup().add(BTN_WIND, BTN_SUN_TIME)
WIND = InlineKeyboardMarkup().add(BTN_WEATHER).add(BTN_SUN_TIME)
SUN_TIME = InlineKeyboardMarkup().add(BTN_WEATHER, BTN_WIND)
HELP = InlineKeyboardMarkup().add(BTN_WEATHER, BTN_WIND).add(BTN_SUN_TIME)
inline_keyboard.py
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup

BTN_WEATHER = InlineKeyboardButton('Weather', callback_data="weather")
BTN_WIND = InlineKeyboardButton('Wind', callback_data="wind")
BTN_SUN_TIME = InlineKeyboardButton('Sunrise and sunset', 
                                    callback_data="sun_time")

WEATHER = InlineKeyboardMarkup().add(BTN_WIND, BTN_SUN_TIME)
WIND = InlineKeyboardMarkup().add(BTN_WEATHER).add(BTN_SUN_TIME)
SUN_TIME = InlineKeyboardMarkup().add(BTN_WEATHER, BTN_WIND)
HELP = InlineKeyboardMarkup().add(BTN_WEATHER, BTN_WIND).add(BTN_SUN_TIME)

Bot main module

Well, in the main module of the bot there is a standard setting, message handlers and callbacks for inline buttons, nothing supernatural.

You need to say at least something. The standard aiogram setup means the following block of code:

import logging

from aiogram import Bot, Dispatcher, executor, types

import config

logging.basicConfig(level=logging.INFO)

bot = Bot(token=config.BOT_API_TOKEN)
dp = Dispatcher(bot)

message handler /start and /weather as follows. Everything works with the magic of aiogram decorators.

@dp.message_handler(commands=['start', 'weather'])
async def show_weather(message: types.Message):
    await message.answer(text=messages.weather(),
                         reply_markup=inline_keyboard.WEATHER)

Callback handler for inline weather button:

@dp.callback_query_handler(text="weather")
async def process_callback_weather(callback_query: types.CallbackQuery):
    await bot.answer_callback_query(callback_query.id)
    await bot.send_message(
        callback_query.from_user.id,
        text=messages.weather(),
        reply_markup=inline_keyboard.WEATHER)

We run the script using the following construction:

if __name__ == '__main__':
    executor.start_polling(dp, skip_updates=True)
bot.py
import logging

from aiogram import Bot, Dispatcher, executor, types

import inline_keyboard
import messages
import config

logging.basicConfig(level=logging.INFO)

bot = Bot(token=config.BOT_API_TOKEN)
dp = Dispatcher(bot)


@dp.message_handler(commands=['start', 'weather'])
async def show_weather(message: types.Message):
    await message.answer(text=messages.weather(),
                         reply_markup=inline_keyboard.WEATHER)


@dp.message_handler(commands="help")
async def show_help_message(message: types.Message):
    await message.answer(
        text=f'This bot can get the current weather from your IP address.',
        reply_markup=inline_keyboard.HELP)


@dp.message_handler(commands="wind")
async def show_wind(message: types.Message):
    await message.answer(text=messages.wind(), 
                         reply_markup=inline_keyboard.WIND)


@dp.message_handler(commands="sun_time")
async def show_sun_time(message: types.Message):
    await message.answer(text=messages.sun_time(), 
                         reply_markup=inline_keyboard.SUN_TIME)


@dp.callback_query_handler(text="weather")
async def process_callback_weather(callback_query: types.CallbackQuery):
    await bot.answer_callback_query(callback_query.id)
    await bot.send_message(
        callback_query.from_user.id,
        text=messages.weather(),
        reply_markup=inline_keyboard.WEATHER
    )


@dp.callback_query_handler(text="wind")
async def process_callback_wind(callback_query: types.CallbackQuery):
    await bot.answer_callback_query(callback_query.id)
    await bot.send_message(
        callback_query.from_user.id,
        text=messages.wind(),
        reply_markup=inline_keyboard.WIND
    )


@dp.callback_query_handler(text="sun_time")
async def process_callback_sun_time(callback_query: types.CallbackQuery):
    await bot.answer_callback_query(callback_query.id)
    await bot.send_message(
        callback_query.from_user.id,
        text=messages.sun_time(),
        reply_markup=inline_keyboard.SUN_TIME
    )


if __name__ == '__main__':
    executor.start_polling(dp, skip_updates=True)

Launching the bot

We look at the logging, you should see 3 messages:

INFO:aiogram:Bot: superultramegaweatherbot [@superultramegaweatherbot]
WARNING:aiogram:Updates were skipped successfully.
INFO:aiogram.dispatcher.dispatcher:Start polling.

So far everything is working, let’s look at the IP from Germany.

There are cases when the request is processed for a long time. I didn’t handle errors or make messages for them, the bot just doesn’t do anything in such cases. I thought it was already good. As the saying goes:

  • Best the enemy of the good

  • Works – don’t touch

  • Another hundred phrases to justify laziness

  • A thousand more soothing phrases for perfectionists

You can also implement the receipt of coordinates by sending geolocation to the bot, then it will turn out many times more accurately.

https://web.telegram.org/k/#@WeathersBot
https://web.telegram.org/k/#@WeathersBot

Shtosh. Thanks for reading. I look forward to feedback, comments and criticism.


GitHub repository shtosh-weather-bot

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *