Asynchronous Telegram bot with aiogram and GINO ORM

Asynchronous Telegram bot with aiogram and GINO ORM

Telegram

Telegram is a cloud-based mobile and desktop messaging app with a focus on security and speed. For me, Telegram - is not just a messenger. It is the first app I open in the morning to read the news. And I am making a significant part of daily communication with Telegram. Plus, it is a developer-friendly service with an open API, so you can create your own clients and apps. More than, Telegram creators are encouraging it and have published a library to speed up that process.

Bots

Bots are third-party applications, hosted on your own server and run inside Telegram. Users can interact with bots by sending them messages, commands and inline requests. You implement the whole bots' logic and control their interaction with users using HTTPS requests to Telegram's Bot API. For example, if you have a website and monitor its availability. You can write a simple bot that will send you an alert if it's offline. There is a nice article about bots on the official website

Creating a bot

To create your own bot, you need to message a special bot - BotFather. In this article, we will create our own bot, that will implement a to-do list app. It should:

  • keep user's notes in the database
  • delete expired items (let's say that 1 hour is the expiry period)
  • be able to fetch and send the whole list of saved notes

So, let's talk to BotFather:

image.png

Screenshot from 2022-07-06 22-45-36.png

Now, we have a bot token, it's everything we need. Let's dive into the code. Important code listings will be provided during the article. The whole repo link is at the bottom.

GINO ORM

First, let me introduce you to the other subject of this article - GINO ORM. Gino is a lightweight asynchronous ORM built on top of SQLAlchemy core for Python asyncio. GINO 1.1 supports PostgreSQL with asyncpg, and MySQL with aiomysql.

image.png

Create a new poetry project and add the required dependencies:

poetry new hashnode-todo
cd hashnode-todo
poetry add python-dotenv aiogram gino emoji

Then let's create models.py (and I would prefer to rename the project folder to "bot"):

from gino import Gino
from sqlalchemy import func

db = Gino()


class User(db.Model):
    __tablename__ = 'users'

    id = db.Column(db.Integer, primary_key=True)
    telegram_user_id = db.Column(db.String(length=200))
    created_at = db.Column(db.DateTime(timezone=True), default=func.now())
    first_name = db.Column(db.String(length=255))
    last_name = db.Column(db.String(length=255))
    username = db.Column(db.String(length=255))


class Note(db.Model):
    __tablename__ = 'notes'

    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    created_at = db.Column(db.DateTime(timezone=True), default=func.now())
    text = db.Column(db.Text)

Also, we need to define a settings file:

import os
from dataclasses import dataclass
from dotenv import load_dotenv


load_dotenv()


@dataclass
class Settings:
    DATABASE_CONNECTION: str
    TELEGRAM_BOT_TOKEN: str


settings = Settings(
    DATABASE_CONNECTION=os.getenv('DATABASE_CONNECTION'),
    TELEGRAM_BOT_TOKEN=os.getenv('TELEGRAM_BOT_TOKEN'),
)

And simple .env file:

DATABASE_CONNECTION=postgresql://postgres@localhost/hashnode_todo
TELEGRAM_BOT_TOKEN=

Now we are ready to work with a database.

For this tutorial, we will initialize the database with gino.create_all() call. In real usage, the migration tool "Alembic" is recommended by GINO authors (and it was created by SQLAcheme author). Or you could assume, that other applications manage the database, so you only define the schema for the already existing database.

The database initialization file:

import asyncio

from bot.models import db
from config import settings


async def main():
    await db.set_bind(settings.DATABASE_CONNECTION)
    await db.gino.create_all()
    await db.pop_bind().close()


if __name__ == '__main__':
    asyncio.get_event_loop().run_until_complete(main())

Now you can run it with poetry run python bot/create_models.py

GINO queries

Let's think about the functions list, that we gonna need. And we know, that Telegram always has the user_id to identify the sender. So we need:

  • add_note(user, text)
  • get_notes(user)
  • process_expired_notes()

And the simple service code with all required basic operations:

from datetime import datetime, timedelta
from typing import List

from bot.models import User, Note


async def add_note(user: 'User', text: str) -> 'Note':
    return await Note.create(
        user_id=user.id,
        text=text
    )


async def get_notes(user: 'User') -> List['Note']:
    return await Note.query.where(Note.user_id == user.id).gino.all()


async def process_expired_notes():
    await Note.delete.where(
        Note.created_at < datetime.now() - timedelta(days=1)
    ).gino.status()

Now we have all database management functionality, and we can use it in the bot

aiogram

aiogram is a pretty simple and fully asynchronous framework for Telegram Bot API written in Python 3.7 with asyncio and aiohttp.

If your bot needs to handle many requests, then asynchronous is your parachute.

Basic bot

Let's start with a simple bot, replying with "hello" on your /start command. The proposed structure is probably not ideal, but very scalable. I use it in some complex projects.

First, we need a base runner file main.py in the project root:

from aiogram import executor

from bot.models import db
from bot.main import dispatcher
from config import settings


async def on_startup(dispatcher):
    await db.set_bind(settings.DATABASE_CONNECTION)


async def on_shutdown(dispatcher):
    await db.pop_bind().close()


if __name__ == '__main__':
    executor.start_polling(
        dispatcher=dispatcher,
        skip_updates=True,
        on_startup=on_startup,
        on_shutdown=on_shutdown,
    )

And file for dispatcher and importing dialogs (bot/main.py):

from aiogram import Bot
from aiogram.dispatcher import Dispatcher
from config import settings


bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
dispatcher = Dispatcher(bot)
Dispatcher.set_current(dispatcher)


import bot.dialogs

Finally, the first dialog file for the "start" command:

from aiogram import types
from aiogram.dispatcher import Dispatcher


dp = Dispatcher.get_current()


@dp.message_handler(commands=['start'])
async def start_handler(message: types.Message):
    await message.answer('Hello from bot!')

Then just run it with poetry run python main.py

Now our simple bot can send the simple answer:

image.png

Middlewares

Middlewares are called on every message, so you can do throttling, logging or add variables to all future handlers. Let's add a middleware, that will get or create a user in the database.

First, adding a new service with one function - get_or_create_user:

from bot.models import User


async def get_or_create_user(
        user_id: str,
        first_name: str,
        last_name: str,
        username: str,
) -> ('User', bool):
    created = False
    user = await User.query.where(User.telegram_user_id == user_id).gino.first()
    if not user:
        user = await User.create(
            telegram_user_id=user_id,
            first_name=first_name,
            last_name=last_name,
            username=username
        )
        created = True

    return user, created

Second, the middleware itself:

from aiogram import types
from aiogram.dispatcher.middlewares import BaseMiddleware

from bot.services.users import get_or_create_user


class UserStorageMiddleware(BaseMiddleware):
    def __init__(self, database):
        self.database = database
        super().__init__()

    async def on_process_message(self, message: types.Message, data: dict):
        user = message.from_user
        db_user, _created = await get_or_create_user(
            user_id=str(user.id),
            first_name=user.first_name,
            last_name=user.last_name,
            username=user.username
        )
        data['user'] = db_user

Pay attention to conditionally-added variables to the data dictionary. If for some reasons, the 'user' is not set in the middleware, and you are declaring it in the handler, you will get an exception!

Third, add this middleware to the dispatcher:

dispatcher = Dispatcher(bot)
Dispatcher.set_current(dispatcher)

dispatcher.middleware.setup(UserStorageMiddleware(database=db))

And you can now use the user variable in handlers. Let's make our bot reply with the user's first name:

@dp.message_handler(commands=['start'])
async def start_handler(message: types.Message, user: 'User'):
    await message.answer(f'Hi, {user.first_name}!')

image.png

Keyboards and state

Keyboards are the last thing we need to implement for the planned note-taking bot. They allow you to show pre-defined actions and easily interact with the bot in one tap.

Let's add a file with a function to return the keyboard (bot/keyboards.py):

from aiogram import types
from aiogram.utils.emoji import emojize

from bot.texts import KEYBOARDS


def main_keyboard() -> types.ReplyKeyboardMarkup:
    markup = types.ReplyKeyboardMarkup(resize_keyboard=True)
    markup.row(
        types.KeyboardButton(emojize(KEYBOARDS['list_notes'])),
        types.KeyboardButton(emojize(KEYBOARDS['add_note']))
    )
    return markup

And a small file with texts:

RESPONSES = {
    'welcome': 'Hello, {user}, I am note taking bot for hashnode',
}

KEYBOARDS = {
    'list_notes': ':spiral_notepad: List notes',
    'add_note': ':heavy_plus_sign: Add note',
}

Plus, modify the start command handler:

@dp.message_handler(commands=['start'])
async def start_handler(message: types.Message, user: 'User'):
    await message.answer(
        RESPONSES['welcome'].format(user=user.first_name),
        reply_markup=main_keyboard(),
    )

And that simple steps allow you to make the bot more interactive:

image.png

Just 2 more steps to finish! Stay tuned :)

Pressing the button just sends its text. So we can use the Text filter in the message handler, to catch that event. Based on that, let's add another dialog file (bot/dialogs/list_notes.py), which will return all your notes

from aiogram import types
from aiogram.dispatcher import Dispatcher
from aiogram.dispatcher.filters import Text
from aiogram.utils.emoji import emojize

from bot.texts import KEYBOARDS
from bot.models import User
from bot.services import notes as notes_service
from bot.keyboards import main_keyboard


dp = Dispatcher.get_current()


@dp.message_handler(Text(equals=emojize(KEYBOARDS['list_notes'])))
async def handler_list_notes(message: types.Message, user: User):
    notes = await notes_service.get_notes(user)

    if not notes:
        answer_text = "You don't have any actual notes"
    else:
        notes_text = '\n\n'.join([
            f'{n.created_at.isoformat()}\n{n.text}' for n in notes
        ])
        answer_text = 'Your notes:\n\n' + notes_text

    await message.answer(
        answer_text,
        reply_markup=main_keyboard()
    )

For adding notes - we gonna use the state. If any state is activated, only related handlers can process the message. So we need to declare the State class and a handler, that will set it. Plus, we gonna replace the keyboard, showing only one "Cancel" button:

class AddNoteState(StatesGroup):
    enter_text = State()


@dp.message_handler(Text(equals=emojize(KEYBOARDS['add_note'])))
async def handler_add_note(message: types.Message):
    await AddNoteState.enter_text.set()
    await message.answer(
        RESPONSES['add_note']['prompt'],
        reply_markup=cancel_keyboard(),
    )

Then we just define the handler for the state, which will save the entered text as a new note:

@dp.message_handler(state=AddNoteState.enter_text)
async def handler_note_text(message: types.Message, state: FSMContext, user: 'User'):
    await notes_service.add_note(user, message.text)
    await state.finish()
    await message.answer(
        RESPONSES['add_note']['success'],
        reply_markup=main_keyboard(),
    )

A full resulting bot interaction:

image.png

A full source code is available on GitHub