Мне нравится создавать Telegram-ботов и экспериментировать с нейросетями, поэтому часто объединяю их в своих в своих проектах. Коллеги заметили мое хобби и предложили разработать новогоднего гида Selectel, который будет генерировать персональные предсказания и советы на 2024 год для наших читателей.

Во время реализации спецпроекта я впервые столкнулся с ограничениями по маркетинговой части проекта. Как заставить ChatGPT придумывать разнообразные, но только положительные предсказания? Как не выйти за лимиты текстовой модели и Telegram? В этой статье делюсь ответами на вопросы и реакцией пользователей на проект.

Используйте навигацию, чтобы выбрать интересующий блок:

Постановка задачи
Архитектура приложения
Замена иллюстраций
Массовая отправка сообщений
Настройка текстовой модели
Статистика
Заключение

Постановка задачи



Макет взаимодействия с ботом. Часть 1.

Перед началом разработки мы с коллегами подробно обсудили характеристики бота (новогоднего гида) и определились с функциональностью.

  • Взаимодействие с ботом происходит через одно сообщение. В терминах Telegram — через InlineKeyboardMarkup.
  • С помощью текстовой модели бот генерирует предсказание или совет — выбор остается за пользователем. При этом предсказание может быть на тему путешествий, отношений, здоровья, образования или карьеры. А совет — о том, какой посмотреть фильм или сериал, какую книгу почитать или что приготовить.
  • На вопрос «Кто я в год дракона?» выводит одну из заготовленных картинок.
  • Новогодний гид должен предлагать пользователям участвовать в розыгрыше, а по завершении — разослать всем результаты. Для этого нужно настроить массовую рассылку сообщений.

Все требования строятся вокруг «фронтенда» и логики бота. Это значит, что я могу использовать любые удобные инструменты для решения задачи.

Архитектура приложения



Диаграмма развертывания.

Основной принцип работы прост: бот должен обращаться к текстовой модели и передавать ответ пользователю. Но здесь появляется загвоздка: часто модель не отвечает мгновенно, а у официальной библиотеки OpenAI нет асинхронного интерфейса. Кроме того, нужно предусмотреть повторы (retry) в случае сетевой или программной ошибки и научиться сохранять задания, чтобы они не слетали при перезагрузке бота.

Чтобы решить эти проблемы, я разделил приложение на две части и делегировал задачи брокеру сообщений. В первой (bot) использовал фреймворк aiogram, который обрабатывает обновления из Telegram Bot API и выполняет «быстрые» задачи — например, навигацию в меню. В нашем случае — получает запрос на совет или предсказание от пользователя и перекладывает его в очередь сообщений.

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

# Компонент bot
@router.callback_query(Callback.filter(F.action == Action.request_answer))
async def handle_prediction_message(query: CallbackQuery, callback_data: Callback, bot: Bot):
    # Сообщение-заглушка пользователю

    # Отправка сообщения в брокер
    request_completion.send(...)


# Компонент worker
async def _request_completion(*args, **kwargs):
    # Логика обращения к текстовой модели
    
    # Отправка результата в Telegram
    await bot.send_photo(...)


@dramatiq.actor
def request_completion(*args, **kwargs):
    loop.run_until_complete(_request_completion(*args, **kwargs))

С архитектурой определились. Далее нужно было сделать меню с красивыми иллюстрациями.

Если вам интересно читать топики о программировании, Telegram, играх и других технологиях, подписывайтесь на мой канал, где периодически пишу на разные темы. А еще — делитесь мнением о пятой версии Midjourney в комментариях.

Замена иллюстраций



Макет взаимодействия с ботом. Часть 2.

Давайте еще раз взглянем на макет. Для каждой «страницы» меню нужно добавить уникальное изображение в виде анимированного стикера. Однако технические ограничения Telegram превращают его в обычное анимированное изображение, поскольку у фото, видео и анимации может быть подпись, а у стикера — нет.

Заглянем в документацию. Telegram Bot API позволяет заменять контент в сообщениях на произвольный, но меню — это фиксированный набор заранее заготовленных изображений. Конечно, мы можем каждый раз добавлять новые, но загрузка однотипных картинок требует много времени, да и такая активность не понравится Telegram. Поэтому, чтобы сэкономить трафик и время на формирование ответа, мы один раз загружаем изображения на серверы Telegram, а далее используем их повторно.

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

  • изображения должны «принадлежать» боту, поскольку именно он их и загружает.
  • кэшированные изображения должны быть в каком-нибудь чате.

Последний пункт заслуживает более подробного объяснения. Если в процессе разработки вы загрузите кэш изображений в вашу переписку с ботом, а потом очистите историю, то первое время все будет работать. Но вскоре Telegram удалит вложения, которые «не привязаны» к чату, и идентификатор станет некорректным.

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

@router.message(Command("/cache"))
async def command_cache(message: Message, bot: Bot) -> None:
    if message.from_user.id not in DEVELOPER_IDS:
        return

    dir = "images"
    for filename in os.listdir(dir):
        if os.path.isdir(f"{dir}/{filename}"):
            continue

        file = FSInputFile(f"{dir}/{filename}")
        if filename.endswith(".png"):
            message = await message.answer_photo(file)
            await pic_update(filename, message.photo[0].file_id)
        else:
            message = await message.answer_video(file)
            await pic_update(filename, message.video.file_id)

    await message.answer("ok")

В результате служебный чат быстро «уедет вниз» и не будет мешать во время тестирования. А это значит, что кэш останется в безопасности.

Массовая отправка сообщений




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

Также у сообщений есть информация, которую нужно где-то хранить, поэтому для этого я использовал две таблицы:

  • new_year_broadcast_message — текст сообщения и идентификатор админа,
  • new_year_broadcast_message_entry — запись об отправке указанного сообщения.

По умолчанию запись об отправке создается со значением sent = False. Это означает, что сообщение еще не было отправлено.

Поскольку Python довольно медленный, перебор пользователей через SQLAlchemy занимает много времени. Поэтому вместо него я использовал SQL:

engine = create_async_engine(...)
async_session: async_sessionmaker[AsyncSession] = async_sessionmaker(engine, expire_on_commit=False)

async with async_session() as session:
    session: AsyncSession
  
    # Создаем сообщение, коммит назначает ему идентификатор
    broadcast = BroadcastMessage(
        from_user_id=user_id,
        text=text
    )
    session.add(broadcast)
    await session.commit()

    # Делаем подзапрос, у которого в первой колонке идентификаторы всех
    # известных пользователей, а во втором — константа
    select_stmt = select(User.id, broadcast.id)

    # Одним запросом создаем по строке на каждого известного пользователя
    stmt = insert(BroadcastMessageEntry).from_select(["user_id", "broadcast_id"], select_stmt)
    await session.execute(stmt)
    await session.commit()

Далее я реализовал функциональность для самой рассылки:

async def do():
    async with async_session() as session:
        session: AsyncSession

        # Лимит в этом запросе ограничивает количество сбщ в секунду
        stmt = select(BroadcastMessageEntry).options(selectinload(BroadcastMessageEntry.message)).where(BroadcastMessageEntry.sent == False).limit(5)
        result = await session.execute(stmt)

        for e in result.fetchall():
            entry: BroadcastMessageEntry = e[0]
            try:
                await bot.send_message(
                    chat_id=entry.user_id,
                    text=entry.message.text,
                )
                entry.sent = True
                logger.info(f"Sending {entry.broadcast_id} to user {entry.user_id} successful")
            except TelegramForbiddenError:
                # Пользователь нас заблокировал
                entry.sent = True
                logger.info(f"Sending {entry.broadcast_id} to user {entry.user_id} failed with blocked chat. Marked as successful")
            except TelegramBadRequest as e:
                # Тоже возможный вариант блокировки
                entry.sent = True
                logger.info(f"Sending {entry.broadcast_id} to user {entry.user_id} failed with BadRequest: {e}. Marked as successful")
            except Exception as e:
                # Ограничение по спаму, недоступность сервера, etc...
                logger.exception(e)
                break

        await session.commit()

async def scheduler():
    # Например, через aioschedule планируем отправку
    aioschedule.every(1).seconds.do(do)

    while True:
        await aioschedule.run_pending()
        await asyncio.sleep(1)

asyncio.create_task(scheduler())

Для отправки массовых сообщений я использовал aioschedule, запущенный в том же цикле событий (event loop), что и aiogram. Ограничение в пять чатов в секунду, конечно, предусмотрительное, но зато хорошо работает.


Настройка текстовой модели


Хитрые пользователи могут «развести» ChatGPT на незапланированные откровения, поэтому от пользовательского ввода мы сразу отказались и оставили только категории. А чтобы снизить стоимость запроса к текстовой модели, решили запрашивать совет или предсказание без истории:

messages = [
    {
        "role": "system",
        "content": "Ты - рекомендательная система. Твой ответ - короткая аннотация."
    },
    {"role": "user", "content": "Посоветуй книгу"},
]
response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    temperature=0.6,
    messages=messages
)

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

  • Фильм — «Побег из шоушенка».
  • Сериал — «Игра престолов».
  • Книга — «1984» Джорджа Оруэлла.

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

_advice_modifier = [
    "жизнерадостной",
    "интригующей",
    "забавной",
    …
]

_advice_book_genre = [
    "жанра фантастика",
    "о морских путешествиях",
    "про поиск работы",
    ...
]


modifier = random.choice(_advice_modifier)
messages = [
    {
        "role": "system",
        "content": "Ты - рекомендательная система. Твой ответ - короткая аннотация."
    },
    {
        "role": "system",
        "content": f"Твоя рекомендация должна быть {modifier}!",
    },
    {
        "role": "system",
        "content": f"Твоего собеседника зовут {full_name}. "
    },
    {
        "role": "user", 
        "content": f"Посоветуй книгу {random.choice(_advice_book_genre)}"
    },
]

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

Василиса Примерова: *выбирает категорию «книги»*.
Бот: Я рекомендую тебе книгу «Королева тьмы» от Василисы Примеровой. Это захватывающее историческое фэнтези, где встречаются сильные женские персонажи, магия и эпические сражения. Погрузись в мир, где прошлое переплетается с фантазией и приключениями!

Список жанров и положительных модификаторов дает достаточное разнообразие в советах без угрозы попасть на непрошенный ужастик. Хотя даже с учетом подбора жанров и отладки на коллегах в «прод» попали следующие советы:

«Хватит мяукать, начни рычать: как стать настоящим львом» — удивительная книга, которая расскажет тебе, как обрести силу и уверенность, примерив на себя манеры настоящего царя джунглей. Забудь о мяуканье и покажи всем, что ты настоящий зверь!

Как насчет «Волшебник-паук и драконья принцесса: Приключения волшебного мира, где драконы играют в бейсбол»? Эта книга полна волшебства, приключений и, конечно же, невероятных игр драконов в любимую американскую игру! Отправляйтесь вместе с главными героями в увлекательное путешествие по фантастическому миру, где мечты сбываются, а драконы — настоящие звезды бейсбола!

Самая потешная категория даже после всех настроек — блюда для готовки. Вот примеры:

Как насчет волшебных тестовых крыльев? Они легкие, хрустящие и просто исчезают во рту!

Как насчет «Курицы-ниндзя»? Это блюдо из куриного филе, обжаренного в соусе терияки с добавлением свежих овощей и немного остроты. Готовься к взрыву вкуса и ниндзя-приключениям на твоем языке!

Попробуй веганскую «Шаурму без шаурмы» — это сочные овощи, обжаренные вкусняшки и ароматные соусы, завернутые в питу. Она настолько вкусная, что даже морковка начнет мечтать о том, чтобы стать шаурмой!

Ах, азиатская кухня — это настоящий фейерверк вкусов! Моя рекомендация — попробуй суши «Драконий огонь»! Это сочные роллы с лососем, авокадо и огненным соусом. Они такие острые, что твой рот будет взрываться вкусом, словно дракон пускает огонь! Ощути яркость вкуса и погружение в азиатскую культуру с этими потрясающими суши!

Острое блюдо? О, я знаю идеальную рекомендацию для тебя — «Вулкан вкуса»! Это блюдо состоит из сочной мясной начинки, огненно-острого соуса и тающего сыра, все это завернуто в лепешку и запечено до золотистой корочки. Гарантирую, что твой вкусовой вулкан точно взорвется от удовольствия! Будь готов к пожару на твоем языке!

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

messages = [
    {
        "role": "system",
        "content": f"Ты пишешь краткие и лаконичные предсказания на 2024 год. Твой ответ - один абзац.",
    },
    {
        "role": "system",
        "content": (
            f"Пиши предсказание, как будто ты {person}."
        )
    },
    {
        "role": "system",
        "content":
            f"Твоего собеседника зовут {full_name}. "
            f"Напиши персонализированное предсказание для этого человека.",
    },
    {
        "role": "system",
        "content": f"Предсказание должно быть {prediction_modifier}",
    },
    {"role": "user", "content": f"Тема: {text}"},
]

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

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

2. Твои отношения будут строиться на взаимопонимании и уважении, что приведет к гармонии и стабильности в твоей личной жизни.

3. Будь открытой и честной с партнером, чтобы создать крепкую и продолжительную связь.

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

5. Не забывай о самолюбии и самоуважении — любовь к себе является фундаментом для здоровых и счастливых отношений.

Далее остается только опытным путем подбирать все модификаторы.

Статистика



За 40 дней работы бота пришло 4 016 пользователей, которые совершили 14 419 запросов. Почти половина запросов — это «Кто я в год дракона?», то есть заранее заготовленные тексты и уникальные иллюстрации. Среди предсказаний пользователей интересует вопрос отношений и карьеры, а среди советов — фильмы и книги.

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


На графике выше видно, что какая-то из маркетинговых активностей привела к «всплеску». 14 декабря было более 5 000 запросов к текстовой модели, а пиковая частота составила пять обращений в секунду. Как и ожидалось, с наступлением нового года активность снизилась и пользователи отключили бота.


Уведомление от BotFather.

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

На графике выше отмечено количество обращений к текстовой модели, но каждое из них — это два-три клика по кнопкам в интерфейсе Telegram, так что в действительности бот обрабатывал больше 300 действий в минуту.

Заключение


Я доволен, что согласился поучаствовать в этом спецпроекте. Это отличная возможность проверить свое умение писать Telegram-ботов на большом количестве пользователей.

Расскажите в комментариях, удалось ли вам ознакомиться с нашим новогодним ботом? Будет интересно узнать ваше мнение.

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


  1. lamooof
    17.01.2024 13:02

    А ссылкой на github не поделитесь?


    1. Firemoon Автор
      17.01.2024 13:02

      Увы, исходный код этого проекта закрыт.


  1. theurus
    17.01.2024 13:02

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