Привет, Хабр! Продолжаю серию статей про разработку 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
— бот будет воспринимать сообщения как промпты для ChatGPTimg_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 к нашему боту, реализуем партнёрскую программу, вывод баланса пользователя и помощи по работе с ботом и обязательную подписку на канал для работы с ботом.
Жду вашего мнения, критики, советов и вопросов в комментариях!