AI-интеграции для Python-разработчика

Подключаем ChatGPT к Telegram-боту: умный бот своими руками

У нас уже есть Telegram-бот на python-telegram-bot и работа с ChatGPT API. Соединяем — получаем бота, который отвечает не эхом, а как ассистент с характером и памятью. Это разбор, который потом станет видео недели 3 — тут же код, что будем записывать.

Версии: Python 3.12, python-telegram-bot 21, openai SDK 1.x.

Архитектура: три слоя на пальцах

сообщение в Telegram → хендлер (PTB) → история диалога (в памяти/SQLite)
                                            ↓
                                    запрос к ChatGPT (openai SDK)
                                            ↓
                         ответ → (опционально стриминг) → сообщение пользователю

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

Базовая версия: память + лимиты

import os

from openai import OpenAI
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes, MessageHandler, filters

TOKEN = os.environ["BOT_TOKEN"]
openai = OpenAI()  # OPENAI_API_KEY и OPENAI_BASE_URL из окружения
MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")

SYSTEM_PROMPT = (
    "Ты — ассистент канала @PythonSimple о Python, Django и AI. "
    "Отвечай по-русски, коротко и по делу, с примерами кода, когда уместно."
)

MAX_HISTORY = 10        # сколько сообщений помнит бот
MAX_TOKENS = 600        # потолок ответа — защита бюджета

# Простое хранилище: chat_id -> история. В проде — SQLite или Redis.
histories: dict[int, list[dict]] = {}


async def chat(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    chat_id = update.effective_chat.id
    history = histories.setdefault(chat_id, [{"role": "system", "content": SYSTEM_PROMPT}])
    history.append({"role": "user", "content": update.message.text})
    history[:] = history[-MAX_HISTORY:]  # держим окно, иначе счёт растёт с каждым сообщением

    response = openai.chat.completions.create(
        model=MODEL,
        messages=history,
        max_tokens=MAX_TOKENS,
        temperature=0.4,
    )
    answer = response.choices[0].message.content
    history.append({"role": "assistant", "content": answer})
    await update.message.reply_text(answer)


async def reset(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    histories.pop(update.effective_chat.id, None)
    await update.message.reply_text("Контекст очищен.")


app = ApplicationBuilder().token(TOKEN).build()
app.add_handler(CommandHandler("reset", reset))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, chat))
app.run_polling()

Работает, но есть две проблемы, которые сразу вылезут в реальном использовании: долгое «печатает...» и растущая история. Чиним обе.

Стриминг: отвечаем до того, как ответ готов

Telegram заканчивает показывать «печатает...» примерно через 5 секунд, а модель думает дольше. Решение из мира чатов: рисуем черновик и дописываем. В Telegram это редактирование сообщения по мере стрима:

async def chat_streaming(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    chat_id = update.effective_chat.id
    history = histories.setdefault(chat_id, [{"role": "system", "content": SYSTEM_PROMPT}])
    history.append({"role": "user", "content": update.message.text})
    history[:] = history[-MAX_HISTORY:]

    placeholder = await update.message.reply_text("…")
    answer, chunk_count = "", 0

    stream = openai.chat.completions.create(
        model=MODEL, messages=history, max_tokens=MAX_TOKENS, temperature=0.4, stream=True,
    )
    for chunk in stream:
        delta = chunk.choices[0].delta.content
        if not delta:
            continue
        answer += delta
        chunk_count += 1
        if chunk_count % 12 == 0:  # Telegram: не чаще ~1 редактирования в секунду
            await placeholder.edit_text(answer + " ▍")

    await placeholder.edit_text(answer)
    history.append({"role": "assistant", "content": answer})

Важная деталь: Telegram ограничивает частоту редактирования сообщения, поэтому обновляем не каждый чанк, а примерно раз в секунду (% 12 при типичной скорости токенов). Используйте либо стриминг, либо обычный вариант из первого примера — смешивать оба хендлера не нужно.

Длинные диалоги: скользящее окно с саммари

Окно в 10 сообщений простое, но бот «забывает» начало разговора. Улучшение: когда история достигает потолка, сжимаем её в саммари — одно системное сообщение вместо десяти:

async def compress_if_needed(history: list[dict]) -> None:
    if len(history) < MAX_HISTORY + 6:
        return
    summary_resp = openai.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": "Сожми диалог в 3–4 предложения: о чём спрашивал пользователь, что отвечали."},
            *history[1:],  # без старого system-промпта
        ],
        max_tokens=200,
    )
    summary = summary_resp.choices[0].message.content
    history[:] = [history[0], {"role": "system", "content": f"Контекст диалога: {summary}"}]

Саммари стоит один дешёвый запрос и экономит десятки дорогих: выигрыш в цене — с третьего длинного диалога.

Хранение: память → SQLite

Словарь histories живёт, пока работает процесс — упал, перезапустился, и все диалоги обнулились. Минимальная надёжность — SQLite (асинхронно через aiosqlite):

import aiosqlite

DB = "bot.db"

async def load_history(chat_id: int) -> list[dict]:
    async with aiosqlite.connect(DB) as db:
        row = await (await db.execute(
            "SELECT messages FROM history WHERE chat_id = ?", (chat_id,)
        )).fetchone()
    import json
    return json.loads(row[0]) if row else [{"role": "system", "content": SYSTEM_PROMPT}]

async def save_history(chat_id: int, history: list[dict]) -> None:
    import json
    async with aiosqlite.connect(DB) as db:
        await db.execute(
            "INSERT INTO history (chat_id, messages) VALUES (?, ?) "
            "ON CONFLICT(chat_id) DO UPDATE SET messages = excluded.messages",
            (chat_id, json.dumps(history, ensure_ascii=False)),
        )
        await db.commit()

Схема одна таблица: chat_id INTEGER PRIMARY KEY, messages TEXT. Для личного бота этого хватит с запасом; для публичного — переезд на Postgres без изменения логики.

Деплой и деньги

  • Запуск — тот же systemd-юнит, что и для простого бота: .venv/bin/python bot.py, Restart=always.
  • Ключ OpenAI — только на сервере. В коде бота ключа нет, утекти может только вместе с сервером.
  • Ограничения как страховка: max_tokens в каждом запросе + счётчик запросов на пользователя в сутки (десятка строк вокруг chat()). Один «попробовавший» пользователь не должен стоить вам сотню рублей.

Итог

Умный Telegram-бот — это 80 строк поверх двух готовых кусков: PTB для Telegram, openai SDK для мозгов, плюс три инженерные детали, о которых молчат туториалы: скользящее окно истории, стриминг вместо «печатает...» и жёсткий потолок токенов. Это же ядро, на котором строятся боты для бизнеса — RAG по документам, запись клиентов, поддержка.

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

FAQ

Сколько стоит держать такого бота?

Сами по себе Telegram Bot API бесплатен. Расходы — только токены OpenAI: для личного бота с парой десятков запросов в день выйдут копейки. Публичный бот на сотни пользователей — ставьте лимиты на историю и max_tokens, иначе счёт непредсказуем.

Как сделать так, чтобы бот отвечал по моим документам, а не общими фразами?

Через RAG: перед запросом к ChatGPT подставлять найденные фрагменты ваших документов в промпт. Это следующий уровень — разбираем в статье про RAG в этом треке. Для старта достаточно качественного system-промпта.

Почему бот иногда долго думает и Telegram показывает 'печатает...'?

Ответ генерируется 5–30 секунд — Telegram таймаутит 'печатает' через ~5 секунд. Решение: стриминг — отправляем черновик сразу и редактируем сообщение по мере генерации. Код ниже.

Что будет, если диалог затянется на сотни сообщений?

Каждый запрос платный и считается по всей истории: длинный диалог = дорогой запрос + риск выйти за лимит контекста модели. Поэтому храним не весь чат, а скользящее окно (последние N сообщений) и по достижении лимита — краткое саммари.

Один разбор в неделю на YouTube

Каждой статье здесь — пара-видео на канале @PythonSimple: та же задача, живой код, коротко.

Подписаться на @PythonSimple