Привет, Хабр! Продолжаю серию статей про разработку telegram-ботов на библиотеке aiogram и языке программирования Python. Хочется отметить, что статья не является документацией или учебником. Я просто рассказываю пошагово как разработать полнофункционального бота, стараясь затронуть как можно больше тем. Если вы не увидели в статье чего-то очень важного по вашему мнению — предложите рассмотреть тему в следующей статье в комментариях.

Всем приятного чтения, жду вашего мнения, критики и вопросов в комментариях.

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

Создание меню

Меню нашего бота будет реализовано с помощью inline кнопок. Для начала надо создать клавиатуру главного меню. Все клавиатуры будут храниться в файле kb.py, поэтому открываем его и пишем в него такой код:

from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove
menu = [
    [InlineKeyboardButton(text="???? Генерировать текст", callback_data="generate_text"),
    InlineKeyboardButton(text="???? Генерировать изображение", callback_data="generate_image")],
    [InlineKeyboardButton(text="???? Купить токены", callback_data="buy_tokens"),
    InlineKeyboardButton(text="???? Баланс", callback_data="balance")],
    [InlineKeyboardButton(text="???? Партнёрская программа", callback_data="ref"),
    InlineKeyboardButton(text="???? Бесплатные токены", callback_data="free_tokens")],
    [InlineKeyboardButton(text="???? Помощь", callback_data="help")]
]
menu = InlineKeyboardMarkup(inline_keyboard=menu)
exit_kb = ReplyKeyboardMarkup(keyboard=[[KeyboardButton(text="◀️ Выйти в меню")]], resize_keyboard=True)
iexit_kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="◀️ Выйти в меню", callback_data="menu")]])

Здесь мы создаём основную клавиатуру menu, сразу же добавляя все кнопки в два столбца. Есть несколько способов создания клавиатур в aiogram 3:

  • Передать двумерный список кнопок как аргумент при создании клавиатуры. Данный способ используется в нашем проекте для создания меню. Удобен когда клавиатура статичная и все данные для неё заранее известны.

  • Использовать Keyboard Builder. Этот способ тоже будет использоваться для создания динамических клавиатур в дальнейшем. Чтобы создать клавиатуру через Keyboard Builder можно использовать следующий код:

builder = InlineKeyboardBuilder()
for i in range(15):
    builder.button(text=f”Кнопка {i}”, callback_data=f”button_{i}”)
builder.adjust(2)
await msg.answer(“Текст сообщения”, reply_markup=builder.as_markup())

Здесь мы создаём Keyboard Builder и в цикле добавляем в него кнопки. builder.adjust(2) группирует кнопки в 2 столбца. Далее отправляется сообщение с созданной клавиатурой, которая из Keyboard Builder преобразовывается в Keyboard Markup функцией builder.as_markup() .

Клавиатура создана, теперь надо написать функции для вывода приветственного сообщения и меню. Тексты сообщений, используемые в боте, мы вынесем в отдельный файл text.py, чтобы можно было менять тексты, используемые в нескольких местах в боте, через изменение одной переменной в text.py.

greet = "Привет, {name}, я бот, использующий нейросети от OpenAI, такие как ChatGPT и Dall-e. Задавай мне вопросы и я постараюсь ответить ☺️"
menu = "???? Главное меню"

Теперь пишем обработчики в файле handlers.py.

from aiogram import F, Router, types
from aiogram.filters import Command
from aiogram.types import Message

import kb
import text

router = Router()

@router.message(Command("start"))
async def start_handler(msg: Message):
    await msg.answer(text.greet.format(name=msg.from_user.full_name), reply_markup=kb.menu)

@router.message(F.text == "Меню")
@router.message(F.text == "Выйти в меню")
@router.message(F.text == "◀️ Выйти в меню")
async def menu(msg: Message):
    await msg.answer(text.menu, reply_markup=kb.menu)

Если вы всё сделали правильно, то после запуска бота (через файл main.py) вы увидите, что бот отвечает на команду /start и на слово «Меню».

Давайте подробнее разберём код, который мы написали. Первая часть файла похожа идентична предыдущей статье, а вот код обработчиков изменился. В функции start вместо отправки строки мы отправляем текст из переменной greet модуля text, причём форматируем его, подставляя имя пользователя (msg.from_user.full_name), а также прикрепляем к сообщению inline-клавиатуру, которую создали до этого. Далее добавился обработчик menu. Как вы могли заметить, перед объявлением функции стоят целых три декоратора. Это означает, что функция запустится, если сработает любой из трёх фильтров. В нашем примере функция будет реагировать на сообщения с текстом «Меню», «Выйти в меню» и «◀️ Выйти в меню». В самой функции нет ничего особенного, она просто отправит текст text.menu с клавиатурой kb.menu. Пока что ни один из наших пунктов меню не работает, давайте исправим это.

Подключение API OpenAI

Сейчас мы реализуем основной полезный функционал нашего бота — работа с нейросетями. Прежде всего стоит отметить, что API не бесплатное, однако при регистрации даётся бесплатно 5$ на счёт. Для тестов нам этого вполне хватит.

Сначала надо получить токен API, для этого регистрируемся на сайте OpenAI, используя VPN и иностранный номер телефона, можно использовать виртуальный, однако не на все виртуальные номера получается зарегистрироваться. После регистрации заходим в аккаунт, в раздел View API Keys и создаём ключ API. Обязательно скопируйте и сохраните ключ в надёжном месте, так как его нельзя будет посмотреть позже, только создать новый.

После того как вы получили API Key его надо сохранить в конфиге, например в переменной OPENAI_TOKEN. Также установите библиотеку openai для работы с API

pip install openai

Теперь когда все приготовления закончены, приступим к функциям для использования API. У нас будет две функции: для генерации текста и для генерации изображений, мы поместим их в файл utils.py, так как они не привязаны к aiogram и могут рассматриваться как внешний модуль. 

import openai
import logging
import config

openai.api_key = config.OPENAI_TOKEN

async def generate_text(prompt) -> dict:
    try:
        response = await openai.ChatCompletion.acreate(
            model="gpt-3.5-turbo",
            messages=[
                {"role": "user", "content": prompt}
            ]
        )
        return response['choices'][0]['message']['content'], response['usage']['total_tokens']
    except Exception as e:
        logging.error(e)
   
async def generate_image(prompt, n=1, size="1024x1024") -> list[str]:
    try:
        response = await openai.Image.acreate(
            prompt=prompt,
            n=n,
            size=size
        )
        urls = []
        for i in response['data']:
            urls.append(i['url'])
    except Exception as e:
        logging.error(e)
        return []
    else:
        return urls

Рассмотрим код подробнее. После импортов мы настраиваем библиотеку openai, давая ей наш ключ от API. Затем объявляем две функции: 

  • generate_text — для генерации текста

  • generate_image — для генерации изображений

В обеих функциях используется конструкция try-catch для обработки исключений, в этом примере мы ничего не делаем, а лишь выводим ошибку в логи и возвращаем пустое значение.

Функция openai.ChatCompletion.acreate генерирует текст с помощью моделей завершения текста. В качестве параметров передаём используемую модель, в нашем случае gpt-3.5-turbo — самая дешёвая и быстрая на данный момент, и сообщения — список словарей с ключами system, user, assistant. Все переданные сообщения будут учтены при создании ответа, подробнее можете почитать в документации.

Мы передаём только сообщение от пользователя и используем поведение модели по умолчанию, но можно также передавать системные сообщения (role: system) для реализации режимов работы, например отдельные функции для написания кода и ответов на теоретические вопросы. Также можно усовершенствовать функцию, чтобы сохранять контекст общения — нейросеть будет «помнить» предыдущие сообщения от пользователя и учитывать их при создании ответа. Всё это дополнительные усовершенствования, которые вы можете попробовать реализовать самостоятельно. Если эта тема будет интересна, то напишу отдельную статью, посвящённую реализации сохранения контекста и переключения режимов работы, поэтому пишите в комментариях, хотите ли видеть такую статью.

Вернёмся к нашим баранам. Последней строкой в функции мы возвращаем кортеж, состоящий из текста ответа от нейросети и количества израсходованных на запрос токенов. Если с текстом ответа всё понятно, то про токены стоит поговорить подробнее. Токен — базовая единица текста, воспринимаемая нейросетью. При отправке запроса специальный инструмент делит промпт (отправленное нейросети сообщение) на токены и в таком виде отдаёт модели. Для модели gpt-3.5-turbo один токен — примерно 3-4 английских буквы, либо 1 русская буква или любой другой символ, не входящий в английский алфавит. Количество использованных токенов определяет сколько вы заплатите за использование API. Для модели gpt-3.5-turbo на момент написания статьи 1 тысяча токенов стоит 0.002 доллара. Согласитесь, не очень много?

Функция генерации изображений очень похожа на предыдущую, но имеет немного другие параметры. Через API можно генерировать как одно, так и сразу несколько изображений, а также можно задать разрешение изображения на выходе. В нашей функции всегда генерируется одно изображение в максимальном разрешении — 1024x1024. Однако мы всё равно перебираем полученные ссылки на изображения в цикле - так будет проще расширять функционал бота в будущем, например дать пользователям возможность генерировать несколько вариантов одного изображения и менять разрешение. Возвращается из функции список, состоящий из ссылок на полученные изображения.

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

Подключение нейросетей к боту

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

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

Все состояния будем создавать в файле states.py

from aiogram.fsm.state import StatesGroup, State

class Gen(StatesGroup):
    text_prompt = State()
    img_prompt = State()

Как видите всё очень просто, мы создали класс Gen и создали в нём два состояния:

  • text_prompt — бот будет воспринимать сообщения как промпты для ChatGPT

  • img_prompt — бот будет воспринимать сообщения как промпты для Dall-e

Последнее приготовление, которое надо сделать — подключить middleware для отображения статуса «Печатает…» во время когда бот ждёт ответа от API. Для этого мы подключим встроенную в aiogram middleware ChatActionMiddleware. В общих чертах middleware это некоторый код (функция, класс), выполняемый до передачи сообщения обработчику. В этом коде можно проверять какие-то данные, сохранять статистику, выполнять какие-либо действия или передавать дополнительные данные в обработчик. Подробнее мы рассмотрим middleware, когда будем реализовывать бан пользователей, а сейчас просто подключим уже готовую middleware. Для этого в файле main.py надо добавить импорт from aiogram.utils.chat_action import ChatActionMiddleware и перед запуском бота вставить строку dp.message.middleware(ChatActionMiddleware()). Так мы подключили middleware для отправки состояний бота пользователям.

Теперь приступаем к основному коду обработчиков. handlers.py. Код, который мы писали ранее дублировать не буду, обращу лишь внимание, что требуется 4 новых импорта:

from aiogram import flags
from aiogram.fsm.context import FSMContext
import utils
from states import Gen

Остальной код:

@router.callback_query(F.data == "generate_text")
async def input_text_prompt(clbck: CallbackQuery, state: FSMContext):
    await state.set_state(Gen.text_prompt)
    await clbck.message.edit_text(text.gen_text)
    await clbck.message.answer(text.gen_exit, reply_markup=kb.exit_kb)

@router.message(Gen.text_prompt)
@flags.chat_action("typing")
async def generate_text(msg: Message, state: FSMContext):
    prompt = msg.text
    mesg = await msg.answer(text.gen_wait)
    res = await utils.generate_text(prompt)
    if not res:
        return await mesg.edit_text(text.gen_error, reply_markup=kb.iexit_kb)
    await mesg.edit_text(res[0] + text.text_watermark, disable_web_page_preview=True)

@router.callback_query(F.data == "generate_image")
async def input_image_prompt(clbck: CallbackQuery, state: FSMContext):
    await state.set_state(Gen.img_prompt)
    await clbck.message.edit_text(text.gen_image)
    await clbck.message.answer(text.gen_exit, reply_markup=kb.exit_kb)

@router.message(Gen.img_prompt)
@flags.chat_action("upload_photo")
async def generate_image(msg: Message, state: FSMContext):
    prompt = msg.text
    mesg = await msg.answer(text.gen_wait)
    img_res = await utils.generate_image(prompt)
    if len(img_res) == 0:
        return await mesg.edit_text(text.gen_error, reply_markup=kb.iexit_kb)
    await mesg.delete()
    await mesg.answer_photo(photo=img_res[0], caption=text.img_watermark)

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

gen_text = "???? Отправьте текст запроса к нейросети для генерации текста"
gen_image = "???? Отправьте текст запроса к нейросети для генерации изображения"
gen_exit = "Чтобы выйти из диалога с нейросетью нажмите на кнопку ниже"
gen_error = f'???? Ошибка генерации. Возможные причины:\n1. Перегружены сервера OpenAI\n2. Ваш запрос нарушил правила OpenAI\n3. Ошибка в работе бота\nЕсли вы считаете, что проблема вызвана неисправностью бота, сообщите админу'
text_watermark = '\n_______________________________________\nСоздано при помощи @dalle_chatgpt_bot'
img_watermark = "Создано при помощи @dalle_chatgpt_bot"
gen_wait = "⏳Пожалуйста, подождите немного, пока нейросеть обрабатывает ваш запрос..."

err = "???? К сожалению произошла ошибка, попробуйте позже"

Теперь проанализируем код обработчиков. В нашем коде впервые встречается такой декоратор как callback_query. Он означает, что функция будет реагировать на нажатия inline-кнопок с определённым фильтром. Если при обработке сообщений надо было использовать выражение F.text, то теперь мы используем F.data.

Также появились новые строки установки состояний через set_state. Данная функция устанавливает для пользователя переданное ей состояние (предварительно созданное как класс). Потом мы обрабатываем сообщения с фильтром состояния @router.message(Gen.text_prompt). Это означает что функция будет реагировать только на те входящие сообщения, которые были отправлены после установки состояния в предыдущей функции.

Ещё появился интересный декоратор @flags.chat_action(«typing»). Именно для его использования мы подключали ChatActionMiddleware. Его функция очень проста — пока выполняется функция, к которой он прикреплён, у пользователя будет отображаться, что бот «печатает…», также можно задать любой другой статус, далее в коде этим же способом устанавливается статус «отправляет фото…». Думаю что весь код с функциями answer и edit_text интуитивно понятен — мы либо отвечаем текстом и клавиатурой на сообщение, либо редактируем то сообщение, от которого нам пришёл Callback Query.

В функциях где работа идёт с функциями из utils выполняется проверка на ошибки — если API вернёт ошибку или она произойдёт в самой функции, то она вернёт нам None. Поэтому перед отправкой пользователю сообщения с ответом мы проверяем, успешно ли функция отработала. К каждому ответу от бота будет прикреплён специальный текст, у меня помещённый в переменную text.text_watermark. Это обеспечит нам некоторую рекламу, если пользователь решит переслать ответ нейросети другому пользователю.

Параметр disable_web_page_preview отвечает за отображение превью ссылок. Нам это ни к чему, поэтому устанавливаем параметр в True.

Теперь наш бот умеет отвечать на текстовые запросы по API и генерировать изображения:

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

Заключение

Что ж, на этом вторая часть заканчивается. Мы сделали немало работы — подключили API OpenAI к Python, создали клавиатуру меню и даже связали это всё вместе! Сейчас бот уже полноценно функционирует и может отвечать на запросы. Однако работа на этом не заканчивается, так как функционал нашего бота намного обширнее. 

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

Жду вашего мнения, критики, советов и вопросов в комментариях!

Комментарии (0)