«База данных» в гугл таблицах для телеграм-бота

jesx7shchub7teq-zjvuye0ih8e.png

Практически все боты используют какое-нибудь хранилище информации. Чаще всего применяются базы данных, но иногда их использование может быть избыточным, особенно если вам не нужны ACID-транзакции и есть желание менять данные руками в максимально простом интерфейсе.

Для хранения информации можно использовать гугл-таблицы.

Сегодня мы разберём пример телеграмм бота для проведения тестов, где вопросы и ответы хранятся в разных вкладках одной Google-таблицы. Почему одной? Опять же, для облегчения доступа для потенциальных пользователей.


Структура Google-таблицы

Перед тем, как начать работать с гугл-таблицей в нашем боте, необходимо определиться с тем, как будет выглядеть структура нашего документа, для того, что бы мы могли написать парсер, который и будем использовать.

В нашем примере мы будем работать с тестами, в которых к каждому вопросу, существует 4 варианта ответа (1 правильный, 3 ошибочные).
Значит, нам нужно хранить следующую информацию о вопросе:

1. Непосредственно текст вопроса
2. Четыре варианта ответа на вопрос
3. Указание, какой из ответов является правильным

Было решено использовать следующую структуру:

zg_k167s5xrp-uxfabfl011qskk.png


  1. В столбце A — находится текст вопроса
  2. В столбце B — правильный ответ
  3. В столбцах C, D, E — неправильные ответы

Такая структура позволит с одной стороны явно указать какой ответ правильный, а с другой мы избежим дублирования ответов.
Кроме этого, можно средствами Google-таблиц реализовать проверки на корректность данных. Например, что среди неправильных ответов нет повторов, или что ни один из неправильных не совпадает с корректным ответом.

И таких листов, с таблицами такого формата может быть сколько угодно, каждая страница — это свой отдельный тест. Можно разделить их, как мы увидим позже, по предметам (Химия/История), а можно даже в рамках одного предмета делать несколько тестов.

Поскольку мы хотим использовать нашу таблицу и для хранения ответов — разберём формат и этой страницы:

oztqan-tptqzmgcdbm6xy9vgjsq.png

В нашей таблице мы будем хранить ID пользователя, вопрос, ответ пользователя, правильный ответ и временную метку.
Зачем? Ведь это явно дублирование информации, по тексту вопроса можно получить ответ из другого листа? Дело в том, что в случае, если лист будет удалён, или исправлен — то и данные в таблице ответов исправятся, что может изменить, например, итоговый бал.

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


Взаимодействие с таблицей из Python

Наша программа на Python будет состоять из двух частей: первая — объекты и методы для работы с Google-таблицей, а вторая — непосредственно бот. Разумеется мы будем использовать первую часть в нашем боте, но для начала разберёмся как работать с гугл-таблицами из Python.

Для работы с API таблиц существует множество библиотек, мы будем использовать gspread. Подробнее о ней можно почитать на сайте с официальной документацией.

Перед началом работы нам нужно создать аккаунт и получить ключи для взаимодействия.
Вот подробная инструкция
о том, как это сделать. В результате — у вас появится .json файл следующего вида:

{
  "type": "service_account",
  "project_id": "pythonapi-433820",
  "private_key_id": "7080a92d01c73eaf214379bb171093",
  "private_key": "-----BEGIN PRIVATE KEY-----\nNrDyLw … jINQh/9\n-----END PRIVATE KEY-----\n",
  "client_email": "api-123@pythonapi-433820.iam.gserviceaccount.com",
  "client_id": "473 … hd.apps.googleusercontent.com"
}

Теперь можно и подключаться.

Для начала создадим файл config.py, в нём мы будем хранить конфигурационную информацию: путь к нашему .json — файлу, ссылку на файл и токен для бота:

CREDENTIALS_FILENAME = "credentials.json"
QUESTIONS_SPREADSHEET_URL = "https://docs.google.com/spreadsheets/d/15dL4-HSC7VjjnQHnppJMQZ"
BOT_TOKEN = "6941340264:BCGscBGpPZIuI-1sOyIgv-rzPgkyrhNt12c"

А теперь перейдём непосредственно к коду нашего парсера. Для начала импортируем все необходимые нам библиотеки:

import gspread
from config import CREDENTIALS_FILENAME, QUESTIONS_SPREADSHEET_URL
from random import shuffle
from datetime import datetime

А затем создадим класс, который будет отображением нашей таблицы в Python:

class Quizzer:
    def __init__(self, question_spreadsheet_url=QUESTIONS_SPREADSHEET_URL):
        self.account = gspread.service_account(filename=CREDENTIALS_FILENAME)
        self.spreadsheet = self.account.open_by_url(question_spreadsheet_url)
        self.topics = {
            elem.title: elem.id for elem in self.spreadsheet.worksheets()
        }
        self.answers = self.spreadsheet.get_worksheet_by_id(self.topics.get("Results"))

Для начала мы создаём необходимые поля класса:


  • self.account — аккаунт который будет использоваться для доступа к таблице. Подтягивается из нашего файла
  • self.url — путь к файлу, с которым мы будем работать
  • self.spreadsheet — непосредственно сам файл (уже открытый)
  • self.topics — словарь пар «Заголовок листа»: «ID листа» — всех листов в нашем файле
  • self.answers — открытый лист с заголовком «Results», куда мы будем записывать ответы

Теперь реализуем необходимые методы:


  1. Получение списка тестов. Список тестов — это все страницы файла, кроме страницы с заголовком «Results»

    def get_topics(self):
        return {key: value for key, value in self.topics.items() if key != "Results"}

  2. Получение списка вопросов в тесте. Мы получим сырые данные со страницы, нужно будет их преобразовать в удобный формат

    def get_question_by_topic(self, topic_name):
        if topic_name in self.topics:
            worksheet = self.spreadsheet.get_worksheet_by_id(self.topics.get(topic_name))
            return worksheet.get_all_records()
        return []
    
    def questions_and_answers(self, topic_name):
        questions = self.get_question_by_topic(topic_name)
        result = []
        for elem in questions:
            answers = [elem["correct_answer"], elem["wrong_answer_1"], elem["wrong_answer_2"], elem["wrong_answer_3"]]
            shuffle(answers)
            new_format = {
                "question": elem["question"],
                "correct_answer": elem["correct_answer"],
                "answers": answers
            }
            result.append(new_format)
        return result

    Так что сначала в методе get_question_by_topic мы получили сырые данные использовав метод get_all_records, а затем в методе questions_and_answers мы собрали список из правильного и неправильных ответов, перемешали его и затем уже вернули список вопросов в том формате, в котором предполагаем его использовать.


  3. А кроме получения информации необходимо ещё и записывать ответы в таблицу. Так что реализуем ещё один метод, для записи:


    def write_answer_to_result_cell(self, user_id, question, answer, correct_answer):
        index = len(list(filter(None, self.answers.col_values(1)))) + 1
        self.answers.update(f"A{index}:E{index}", [[
            user_id, question, answer, correct_answer, f"{datetime.now()}"
        ]])

В принципе, на этом функционал по взаимодействию с гугл-таблицей реализован. И можно переходить к написанию собственно бота.


Телеграм-бот

Для реализации телеграмм бота будем использован асинхронный движок — aiogram. Подробнее о реализации ботов с помощью этой библиотеки можно прочитать в этом учебнике.

Для начала импортируем все необходимые библиотеки:

import asyncio

from aiogram import Bot, Dispatcher, F, Router, types
from aiogram.filters.command import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup

from config import BOT_TOKEN
from questionExtractor import Quizzer


  • asyncio — библиотека для работы с асинхронным подходом
  • aiogram — непосредственно библиотека для создания ботов, извлекаем основные понятия и необходимый функционал для
    работы со Стейт-Машиной
  • config — наш файл конфигурации, BOT_TOKEN — токен для взаимодействия с ботом. Получить можно с помощью 'BotFather'
  • questionExtractor — наш модуль для взаимодействия с гугл-таблицей

Затем создаём необходимые базовые объекты:


  • router = Router() — роутер, для распределения сообщений по обработчикам
  • bot = Bot(token=BOT_TOKEN) — непосредственно сам бот
  • dp = Dispatcher() — диспетчер сообщений
  • quizzer = Quizzer() — объект для взаимодействия с таблицей

а затем создаём класс, описывающий все возможные состояния нашего бота:

class CurrentQuiz(StatesGroup):
    start = State()
    choosing_test = State()
    question = State()


  • start — бот только запущен (или перезапущен) — нужно выбрать тест
  • choosing_test — процесс выбора теста — отправка первого вопроса
  • question — отправка вопросов и приём ответов

Перед непосредственной обработкой входящих сообщений напищем две вспомогательные функции.

Одна будет использоваться для создания кастомной клавиатуры из списка возможных ответов:

def create_keyboard(options):
    """Функция для создания клавиатуры из списка возможных вариантов"""
    return types.ReplyKeyboardMarkup(keyboard=[[types.KeyboardButton(text=f"{elem}")] for elem in options])

А вторая, для формирования сообщения с вопросом и клавиатурой. Важно сказать, что мы храним всё наше текущее состояние в стейте. Стейт (состояние) — это некий словарь, где мы можем хранить какую-то информацию, причём этот словарь можно передавать между обработчиками.

В поле current_question — мы храним тот вопрос, который хотим отправить, а в поле choosing_test — список вопросов текущего теста.

async def ask_question(message: types.Message, state: FSMContext):
    """Функция для отправки вопроса с формированием клавиатуры ответов"""
    data = await state.get_data()
    question = data["current_question"]
    keyboard = create_keyboard(question["answers"])
    await message.answer(question["question"], reply_markup=keyboard)
    await state.update_data(current_question=question)
    await state.update_data(choosing_test=data["choosing_test"][1:])

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

Теперь пришло время написать обработчики пользовательских сообщений.

# Обработчик на команду старт. Стейт CurrentQuiz.start
@router.message(CurrentQuiz.start)
@router.message(Command("start"))
async def cmd_start(message: types.Message, state: FSMContext):
    keyboard = create_keyboard(quizzer.get_topics().keys())
    await message.answer("Привет, я бот Quizzer. Вот доступные темы для тестов. Выбери любую", reply_markup=keyboard)
    await state.set_state(CurrentQuiz.choosing_test)

Это обработчик первой команды которую отправляет пользователь, запуская новый бот — /start. В ней мы получаем список тем из нашей таблицы, а затем предлагаем пользователю выбрать одну из них.

# Обработчик стейта выбора теста
@router.message(CurrentQuiz.choosing_test, F.text.in_(quizzer.get_topics().keys()))
async def start_quizz(message: types.Message, state: FSMContext):
    chosen_test_title = message.text
    choosing_test = quizzer.questions_and_answers(message.text)

    await state.update_data(
        choosing_test=choosing_test,
        current_question=choosing_test[0]
    )

    await message.answer(f"Выбрана тема: {chosen_test_title}")
    await state.set_state(CurrentQuiz.question)
    await ask_question(message, state)

После того как пользователь выберет тест, мы сохраняем в стейт вопросы из выбранного теста:

    await state.update_data(
    choosing_test=choosing_test,
    current_question=choosing_test[0]
)

И задаём пользователю первый вопрос из него: await ask_question(message, state)

Самый важный обработчик — это обработчик получения ответа от пользователя:

@router.message(CurrentQuiz.question)
async def getting_answer(message: types.Message, state: FSMContext):
    data = await state.get_data()
    quizzer.write_answer_to_result_cell(
        message.from_user.username,
        data["current_question"]["question"],
        message.text,
        f'{data["current_question"]["correct_answer"]}'
    )

    remaining_questions = data["choosing_test"]

    if remaining_questions:
        await state.update_data(choosing_test=remaining_questions, current_question=remaining_questions[0])
        await ask_question(message, state)
    else:
        await state.clear()
        await message.answer("Все вопросы закончились", reply_markup=create_keyboard(["Выбрать новый квиз"]))
        await state.set_state(CurrentQuiz.start)

Во-первых мы сохраняем ответ пользователя в таблицу:

    quizzer.write_answer_to_result_cell(
        message.from_user.username,
        data["current_question"]["question"],
        message.text,
        f'{data["current_question"]["correct_answer"]}'
    )

А затем проверяем, остались ли в тесте незаданные вопросы. Если остались — задаём следующий:

        await state.update_data(choosing_test=remaining_questions, current_question=remaining_questions[0])
        await ask_question(message, state)

А если нет — очищаем память стейта и возвращаемся к исходному стейту — запуску бота:

        await state.clear()
        await message.answer("Все вопросы закончились", reply_markup=create_keyboard(["Выбрать новый квиз"]))
        await state.set_state(CurrentQuiz.start)

Теперь остаётся только запустить нашего бота:

# Запуск процесса поллинга новых апдейтов
async def main():
    dp.include_router(router)
    await dp.start_polling(bot)

if __name__ == "__main__":
    asyncio.run(main())

Наш бот заработал на локальном компьютере, но в тот момент, когда мы его выключим наш бот отключится. Что бы работа бота не зависела от состояния вашего компьютера — принято загружать (деплоить) их в облако. Этим мы сейчас и займёмся.


Деплой

В качестве облака для деплоя будем использовать Amvera.

Сервис позволит осуществить деплой простым перетягиванием файлов в интерфейсе (или через команду git push amvera master в IDE) и предоставит стартовый баланс на первые недели бесплатного использования.

Создаём проект.

dipza-koegbcvyuafz3hpssesim.png

Загружаем необходимые файлы проекта, включая конфигурационные, а также файл с зависимостями. Его можно сгенерировать автоматически.
Используя например команду: pip freeze > requirements.txt.
Но лучше написать руками, pip freeze генерирует много лишних зависимостей, которые замедляют сборку.

ip8dyfvv7q8zlovr_aznqu4uvg8.png

Затем конфигурируем: указываем версию python, механизм умправления зависимостями и основной файл программы:

ag-di8ov9xp6xepq1fqwgukglq4.png

Запускаем сборку и наслаждаемся работой нашего бота.

Если вы хотите использовать для деплоя Git, рекомендую ознакомиться со статьей.

В гугл-таблице у нас вопросы и ответы:

zzdwzkb6sdkw3-i0he6sh-lpzoi.png

5_ntmrbozd57eh1nen08vaoorrk.png

А всё взаимодействие — через бота:

wgpvmstjiifnfjifn1otjjvtzsw.png

В этом уроке мы познакомились с тем как использовать Google-таблицу в качестве «БД» для телеграм бота. Полный код проекта доступен в репозитории на GitHub

© Habrahabr.ru