Улучшаем Telegram-бота: интерактивная клавиатура, FSM

В предыдущей части мы написали простой шахматный бот.

В предыдущей версии мы были обязаны отправлять ходы сообщениями. Это не очень удобно: необходимо печатать, плюс можно опечататься; также не все знают UCI нотацию ходов.

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

Интерактивная клавиатура

chess_utils.py:

from io import BytesIO
import random
from typing import Optional

import chess
import chess.svg
from cairosvg import svg2png

from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton


def generate_board_image(board: chess.Board) -> bytes:
    svg_image = chess.svg.board(board)
    png_image = BytesIO()
    svg2png(bytestring=svg_image, write_to=png_image)
    png_image.seek(0)
    return png_image.read()


def first_tap_keyboard(board: chess.Board) -> InlineKeyboardMarkup:
    kb_list = []
    squares_added = set()
    for move in board.legal_moves:
        square = move.from_square
        if square in squares_added:
            continue
        squares_added.add(square)

        kb_list.append([
            InlineKeyboardButton(
                text=f"{board.piece_at(square).unicode_symbol()} {chess.square_name(square)}",
                callback_data="turn_" + chess.square_name(square),
            )
        ])

    keyboard = InlineKeyboardMarkup(inline_keyboard=kb_list)
    return keyboard

def second_tap_keyboard(board: chess.Board, first_square_name: str) -> InlineKeyboardMarkup:
    square = chess.parse_square(first_square_name)
    piece = board.piece_at(square)

    kb_list = []
    for move in board.legal_moves:
        if move.from_square == square:
            to_square = chess.square_name(move.to_square)
            if move.promotion is not None:
                promotion = chess.piece_symbol(move.promotion)
                promotion_str = " → " + chess.Piece(move.promotion, chess.WHITE).unicode_symbol()
            else:
                promotion = promotion_str = ""

            kb_list.append([
                InlineKeyboardButton(
                    text=f"{piece.unicode_symbol()}{to_square}{promotion_str}",
                    callback_data="turn2_" + to_square + promotion,
                )
            ])

    kb_list.append([
        InlineKeyboardButton(
            text=f"Отмена", callback_data="turn2_cancel",
        )
    ])
    keyboard = InlineKeyboardMarkup(inline_keyboard=kb_list)
    return keyboard


def make_move(board: chess.Board, move: str) -> Optional[chess.Outcome]:
    uci_move = chess.Move.from_uci(move)
    if not board.is_legal(uci_move):
        raise chess.IllegalMoveError
    board.push(uci_move)
    return board.outcome()


def make_bot_move(board: chess.Board) -> Optional[chess.Outcome]:
    bot_move = random.choice(list(board.legal_moves))
    board.push(bot_move)
    return board.outcome()

Функции first_tap_keyboard и second_tap_keyboard генерируют инлайн-клавиатуры для заданной позиции на шахматной доске.

Использоваться они будут примерно так:

await message.message.edit_reply_markup(
    reply_markup=second_tap_keyboard(board, square_name),
)

А выглядит это примерно так (вид зависит от используемого клиента Telegram):

Inline клавиатура для Telegram бота: как она выглядит в Telegram

Мы разбили ввод хода на 2 этапа для того, чтобы сильно уменьшить количество кнопок на каждой из клавиатур. Поэтому у нас есть first_tap_keyboard и second_tap_keyboard.

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

Функция возвращает объект InlineKeyboardMarkup. Инлайн-клавиатура состоит из набора кнопок, а конкретно двумерного массива из них. Каждая кнопка - это объект InlineKeyboardButton. У каждой кнопки есть текст (аргумент text), и, в нашем случае, аргумент callback_data - это данные, которые будут передаваться нашему боту при нажатии на эту кнопку.

Шахматный бот генерирует кнопки для каждого из возможных ходов на доске.

Храним состояние

Однако, во время нажатия второй кнопки, в боте надо запоминать, а какая же первая кнопка была нажата (а в общем случае еще и понимать, нажата ли).

Можно хранить значение нажатия первой кнопки там же, где и доску (сейчас это в Python-словаре, далее мы перейдем на базы данных). Однако, в aiogram есть способ хранить некоторый объем данных в состоянии пользователя.

Этот способ реализован в подмодуле fsm (от англ. Finite-state machine). Приведу сразу код улучшенного chessbot.py:

import asyncio
import logging
import sys

from aiogram import Bot, Dispatcher, F
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.filters import CommandStart, Command, StateFilter
from aiogram.types import Message, BufferedInputFile, CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State

import chess

from my_credentials import API_TOKEN
from chess_utils import generate_board_image, first_tap_keyboard, second_tap_keyboard, make_move, make_bot_move


dp = Dispatcher()
boards = {}

class ChessMatchStatesGroup(StatesGroup):
    has_chosen_piece = State()


@dp.message(CommandStart())
async def send_welcome(message: Message) -> None:
    await message.reply("Привет! Я ваш первый Telegram-бот.")

@dp.message(Command("newgame"))
async def start_game(message: Message) -> None:
    initial_board = chess.Board()
    boards[message.from_user.id] = initial_board
    await message.answer_photo(
        photo=BufferedInputFile(generate_board_image(initial_board), "game.png"),
        caption="Игра началась! Делай первый ход. Нажми на клавиатуру: первым нажатием выбери фигуру, вторым - куда она пойдет",
        reply_markup=first_tap_keyboard(initial_board),
    )

@dp.callback_query(StateFilter(None), F.data.startswith("turn_"))
async def process_from_move(message: CallbackQuery, state: FSMContext) -> None:
    if message.from_user.id not in boards:
        return
    board = boards[message.from_user.id]
    square_name = message.data.removeprefix("turn_")
    await state.set_data({"square": square_name})
    await state.set_state(ChessMatchStatesGroup.has_chosen_piece)
    await message.message.edit_reply_markup(
        reply_markup=second_tap_keyboard(board, square_name),
    )


@dp.callback_query(ChessMatchStatesGroup.has_chosen_piece, F.data == "turn2_cancel")
async def process_to_cancel(message: Message, state: FSMContext) -> None:
    if message.from_user.id not in boards:
        return
    board = boards[message.from_user.id]
    await state.clear()
    await message.message.edit_reply_markup(
        reply_markup=first_tap_keyboard(board),
    )


@dp.callback_query(ChessMatchStatesGroup.has_chosen_piece, F.data.startswith("turn2_"))
async def process_to_move(message: Message, state: FSMContext) -> None:
    async def send_end_of_game():
        await message.message.delete()
        await message.message.answer_photo(
            photo=BufferedInputFile(generate_board_image(board), "game.png"),
            caption=f"Игра закончена! Результат - {outcome.result()}. Новая игра - /newgame"
        )
        del boards[message.from_user.id]
        await state.clear()

    if message.from_user.id not in boards:
        return

    board = boards[message.from_user.id]
    square_name = message.data.removeprefix("turn2_")
    data = await state.get_data()

    outcome = make_move(board, f"{data.get('square')}{square_name}")
    if outcome is not None:
        await send_end_of_game()
        return

    outcome = make_bot_move(board)
    if outcome is not None:
        await send_end_of_game()
        return

    await message.message.delete()
    await message.message.answer_photo(
        photo=BufferedInputFile(generate_board_image(board), "game.png"),
        caption=f"Делайте следующий ход!",
        reply_markup=first_tap_keyboard(board),
    )
    await state.clear()


async def main() -> None:
    bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
    await dp.start_polling(bot)


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, stream=sys.stdout)
    asyncio.run(main())

Первое, что бросилось в глаза, это, конечно же, использование inline_markup, а второе - это использование FSM:

class ChessMatchStatesGroup(StatesGroup):
    has_chosen_piece = State()

Состояния представлены в виде подкласса класса StatesGroup. Атрибуты в нем - названия возможных состояний.

Вместо @dp.message() у нас вот такая конструкция:

@dp.callback_query(StateFilter(None), F.data.startswith("turn_"))
async def process_from_move(message: CallbackQuery, state: FSMContext) -> None:
    ...

Вместо .message() у нас .callback_query. Этот декоратор вешает обработчик не на отправку сообщения боту, как в прошлой части, а на отправку данных с кнопки инлайн-клавиатуры.

В качестве аргументов у нас 2 фильтра: первый, StateFilter(None), истинен тогда, когда не установлено состояние. Второй, F.data.startswith("turn_"), это F-выражение из aiogram, истинно тогда, когда переданное значение callback_data начинается с turn_.

Функция же теперь вместо объекта типа Message, принимает объект типа CallbackQuery. Также функция принимает аргумент state класса FSMContext, который есть объект состояния.

await state.set_data({"square": square_name})
await state.set_state(ChessMatchStatesGroup.has_chosen_piece)

Сохраняет данные для состояния и устанавливает состояние, соответственно.

@dp.callback_query(ChessMatchStatesGroup.has_chosen_piece, F.data == "turn2_cancel")
async def process_to_cancel(message: Message, state: FSMContext) -> None:
    await state.clear()

Фильтр ChessMatchStatesGroup.has_chosen_piece истинен только, если состояние установлено в значение has_chosen_piece.

В нашем примере состояние только одно (соответственно, оно либо установлено, либо нет). В Вашем проекте состояний может быть (и будет) больше.

await state.clear() очищает состояние, и устанавливает его в None. Если ничего не сделать с состоянием в функции, то оно просто не изменится (и иногда это то, что нам нужно).

Архив с кодом Telegram-бота (без файла с токеном) можно скачать здесь.

Популярные статьи

Объект range

Изучение объекта range в Python. Создание последовательностей чисел, использование в циклах и примеры применения.

PEP 257 - соглашения для строк документации (docstrings)

Целью данного PEP является стандартизация структуры строк документации: что они должны содержать и что должны объяснять.

Бесплатные курсы Python

Обзор бесплатных курсов, обучающих видео по языку программирования Python