Знаменитый AI чат-бот ChatGPT заблокирован в России. Отличным выходом из этой ситуации были и есть Telegram-боты, которые не только позволяют обходить блокировку, но и в целом делают использование бота гораздо более удобным прямо из мессенджера.

Я решил сделать свою интеграцию ChatGPT в Telegram, чтобы лучше понять, как работает ChatGPT API, какие настройки мне доступны и пользоваться ботом без всяких ограничений, а также иметь свободный доступ к модели GPT-4.

Мне не хотелось для этого проекта держать отдельный сервер, покупать домен и делать под него SSL сертификат, который требует Telegram для настройки WebHook. Поэтому я решил настроить всю систему с помощью serverless-технологий.

Подготовка

Для реализации проекта мне понадобится:

API-ключ для ChatGPT API

Доступ к API, как и к самому ChatGPT заблокирован в России. К счастью, есть простое решение этой проблемы - ChatGPT API в России от компании ProxyAPI. Не нужен ни иностранный телефон, ни VPN, ни карта иностранного банка. 

Регистрируемся, идём в раздел Ключи API и создаём ключ. Одна минута и готово. Красота!

Сохраните ключ во время создания! В полном виде вы его больше не увидите
Сохраните ключ во время создания! В полном виде вы его больше не увидите

Аккаунт в Яндекс.Облако

Если аккаунта ещё нет его нужно создать здесь. Убедитесь, что у вас подключён платёжный аккаунт, и он находится в статусе ACTIVE или TRIAL_ACTIVE.

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

Telegram-бот

  1. Для создания и управления своими ботами в Telegram есть, собственно, специальный бот под названием BotFather. Он поможет вам создать бота и в результате даст токен - сохраните его, он нам ещё понадобится.

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

    Сначала откройте свой Telegram-бот по ссылке, которую предоставил BotFather. Напишите любое сообщение (команды /start недостаточно, нужно именно сообщение).

    Теперь можно открыть в браузере следующий URL:
    https://api.telegram.org/bot<token>/getUpdates

    Замените <token> на токен, который мы получили в прошлом шаге.
    Вы увидите что-то вроде этого:
    {"ok":true,"result":[{"update_id":1234567890, "message":{"message_id":218,"from":{"id":1234567890,"is_bot":false,"first_name":"User","username":"username","language_code":"en"},"chat":{"id":1234567890,"first_name":"User","username":"username","type":"private"},........

    Нам нужен ID из этого куска:
    "chat":{"id":1234567890

    Если хотите поделиться своим ботом с друзьями, попросите их проделать такую же операцию и дать вам их идентификатора чата.

Облачные ресурсы

Теперь возвращаемся в Яндекс Облако и заводим все ресурсы, необходимые для работы нашего проекта.

  1. Сервисный аккаунт

На домашней странице консоли в верхнем меню есть вкладка "Сервисные аккаунты". Переходим туда и создаём новый аккаунт. Здесь и везде далее я использую одно и то же имя для всех ресурсов "chatgpt-telegram-bot" просто, чтобы не запутаться. Аккаунту надо присвоить следующие роли:
serverless.functions.invoker
storage.uploader

После того как аккаунт создан, перейдите в него и создайте статический ключ доступа, сохраните полученные идентификатор и секретный ключ, а также идентификатор самого сервисного аккаунта.

  1. Бакет

Теперь переходим в раздел "Object Storage" и создаём новый бакет. Я не менял никакие настройки.

  1. Облачная функция

Следующий шаг — создание облачной функции. Именно она будет получать ваши запросы от Telegram, перенаправлять их в ProxyAPI и посылать ответ обратно в Telegram-бот.

Переходим в раздел "Cloud Functions" и жмём "Создать функцию".

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

Выбираем среду выполнения Python 3.11:

В редакторе сначала создадим новый файл, назовём его requirements.txt и положим туда следующий код:

openai==0.28.1
pyTelegramBotAPI==4.14.0
boto3==1.28.62

Это список зависимостей для Python, которые облако автоматически подгрузит во время сборки так, чтобы мы могли пользоваться ими в коде самой функции.

Теперь переключимся на редактирование index.py и запишем в него этот код:

import logging
import telebot
import os
import openai
import json
import boto3
import time
import multiprocessing

TG_BOT_TOKEN = os.environ.get("TG_BOT_TOKEN")
TG_BOT_CHATS = os.environ.get("TG_BOT_CHATS").split(",")
PROXY_API_KEY = os.environ.get("PROXY_API_KEY")
YANDEX_KEY_ID = os.environ.get("YANDEX_KEY_ID")
YANDEX_KEY_SECRET = os.environ.get("YANDEX_KEY_SECRET")
YANDEX_BUCKET = os.environ.get("YANDEX_BUCKET")


logger = telebot.logger
telebot.logger.setLevel(logging.INFO)

bot = telebot.TeleBot(TG_BOT_TOKEN, threaded=False)

openai.api_key = os.getenv("PROXY_API_KEY")
openai.api_base = "https://api.proxyapi.ru/openai/v1"


def get_s3_client():
    session = boto3.session.Session(
        aws_access_key_id=YANDEX_KEY_ID, aws_secret_access_key=YANDEX_KEY_SECRET
    )
    return session.client(
        service_name="s3", endpoint_url="https://storage.yandexcloud.net"
    )


def typing(chat_id):
    while True:
        bot.send_chat_action(chat_id, "typing")
        time.sleep(5)


@bot.message_handler(commands=["help", "start"])
def send_welcome(message):
    bot.reply_to(
        message,
        ("Привет! Я ChatGPT бот. Спроси меня что-нибудь!"),
    )


@bot.message_handler(commands=["new"])
def clear_history(message):
    try:
        s3client = get_s3_client()
        s3client.put_object(
            Bucket=YANDEX_BUCKET,
            Key=f"{message.chat.id}.json",
            Body=json.dumps([]),
        )
    except:
        pass

    bot.reply_to(message, "История чата очищена!")


@bot.message_handler(func=lambda message: True, content_types=["text"])
def echo_message(message):
    typing_process = multiprocessing.Process(target=typing, args=(message.chat.id,))
    typing_process.start()

    # read current chat history
    s3client = get_s3_client()
    history = []
    try:
        history_object_response = s3client.get_object(
            Bucket=YANDEX_BUCKET, Key=f"{message.chat.id}.json"
        )
        history = json.loads(history_object_response["Body"].read())
    except:
        pass

    history.append({"role": "user", "content": message.text})

    try:
        chat_completion = openai.ChatCompletion.create(
            model="gpt-3.5-turbo", messages=history
        )
    except Exception as e:
        if type(e).__name__ == "InvalidRequestError":
            clear_history(message)
            echo_message(message)
            return

        bot.reply_to(message, "Произошла ошибка, попробуйте позже!")
        return

    ai_response = chat_completion.choices[0]["message"]["content"]
    bot.reply_to(message, ai_response)

    history.append({"role": "assistant", "content": ai_response})

    # save current chat history
    s3client.put_object(
        Bucket=YANDEX_BUCKET,
        Key=f"{message.chat.id}.json",
        Body=json.dumps(history),
    )

    typing_process.terminate()


def handler(event, context):
    message = json.loads(event["body"])
    update = telebot.types.Update.de_json(message)

    if str(update.message.chat.id) in TG_BOT_CHATS:
        bot.process_new_updates([update])

    return {
        "statusCode": 200,
        "body": "ok",
    }

Разберём, что делает этот код.

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

Инициируем библиотеку для работы с Telegram:

bot = telebot.TeleBot(TG_BOT_TOKEN, threaded=False)

Важно! Обязательно укажите параметр threaded=False, иначе обработка сообщений от Telegram будет запускаться в отдельном потоке, однако в связи с тем, что это не полноценный сервер, который включён всегда, а облачная функция, она прекратит свою работу как только будет получен ответ от метода handler и просто проигнорирует исполняющийся на другом потоке процесс. В результате вы просто не получите ответ.

Далее переопределяем API-ключ и путь к API для OpenAI SDK, чтобы библиотека обращалась к нашему ProxyAPI, а не к OpenAI напрямую:

openai.api_key = os.getenv("PROXY_API_KEY")
openai.api_base = "https://api.proxyapi.ru/openai/v1"

 def get_s3_client()

Метод для получения клиента для работы с Object Storage. В наш бакет мы будем сохранять историю беседы.

def typing(chat_id)

Метод, который будет посылать в Telegram статус "Набирает сообщение…", чтобы ожидание ответа не было таким томительным :)

@bot.message_handler(commands=["help", "start"])

Приветственное сообщение, которое пришлёт бот в ответ на команды /start или /help

@bot.message_handler(commands=["new"])

Команда /new позволяет очистить текущую историю чата, чтобы ChatGPT больше не использовал нерелевантный контекст, когда вы, например, хотите начать обсуждать новую тему так, чтобы бот не "отвлекался" на предыдущую беседу.

@bot.message_handler(func=lambda message: True, content_types=["text"])

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

def handler(event, context)

Это "входная точка" для вызова облачной функции. Здесь мы просто декодируем сообщение в JSON, проверяем, что оно поступило из списка чатов, которые мы хотим поддерживать, то есть запрос прислали вы, а не кто-то другой и отдаём его на обработку библиотеке Telegram-бота.


В параметрах функции поставим таймаут 60 секунд - ответы от ChatGPT приходится обычно ждать какое-то время, 60 секунд должно быть достаточно.

А также надо заполнить все переменные окружения, которые использует наша функция.

TG_BOT_TOKEN

Токен Telegram-бота

TG_BOT_CHATS

ID авторизованных чатов Telegram, разделённых через запятую

PROXY_API_KEY

API-ключ от ProxyAPI

YANDEX_KEY_ID

YANDEX_KEY_SECRET

Идентификатор и секретный ключ сервисного аккаунта Яндекс

YANDEX_BUCKET

Имя бакета, который вы создали в Object Storage


На этом наша работа с функцией закончена. Жмём "Сохранить изменения" и смотрим, как Облако собирает нашу функцию. Для следующего шага нам понадобится идентификатор функции. Перейдите во вкладку "Обзор" для нашей функции и скопируйте его оттуда.

АПИ шлюз

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

openapi: 3.0.0
info:
  title: Sample API
  version: 1.0.0
paths:
  /:
    post:
      x-yc-apigateway-integration:
        type: cloud-functions
        function_id: <FUNCTION-ID>
        service_account_id: <SERVICE-ACCOUNT-ID>

В спецификации используйте свой индентификатор функции и сервисного аккаунта для значений <FUNCTION-ID> и <SERVICE-ACCOUNT-ID>. Выглядеть это будет вот так:

После сохранения вы увидите сводную информацию о шлюзе. Сохраните оттуда строку "Служебный домен".

Telegram WebHook

Теперь надо сообщить Telegram-боту, куда пересылать сообщения, которые он от нас получает. Для этого достаточно выполнить POST-запрос к API Telegram такого формата:

curl \
  --request POST \
  --url https://api.telegram.org/bot<токен бота>/setWebhook \
  --header 'content-type: application/json' \
  --data '{"url": "<домен API-шлюза>"}'

<токен бота> заменяем на токен Telegram-бота, который мы получили еще на третьем шаге этого туториала

<домен API-шлюза> заменяем на Служебный домен нашего API-шлюза, созданный на прошлом шаге.

Я использовал Postman для этой задачи, просто удобнее, когда всё наглядно и с user-friendly интерфейсом:

На этом вся наша работа закончена, осталось только проверить.

Тест

Спрошу у своего чат-бота топ-10 стран по численности населения, а потом уточню, что интересуют страны только в Европе. Проверим, сможет ли он поддерживать диалог и работать с уточнениями.

Ура! Всё работает!

Стоимость ресурсов

Мы используем три типа ресурсов на Яндекс Облаке. Вот бесплатные лимиты потребления для каждого из них за каждый месяц.

Cloud Functions

1 миллион вызовов;
10 Гб/час использования памяти

Подробнее:
https://cloud.yandex.ru/docs/functions/pricing

Object Storage

первый 1 ГБ в месяц хранения;
первые 10 000 операций PUT, POST;
первые 100 000 операций GET, HEAD, OPTIONS

Подробнее:
https://cloud.yandex.ru/docs/storage/pricing

API-шлюз

Каждый месяц не тарифицируются первые 100 000 запросов к API-шлюзам.

Подробнее:
https://cloud.yandex.ru/docs/api-gateway/pricing

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

Единственные расходы будут связаны с использованием ChatGPT API. Цены здесь.


Итог

Теперь у нас есть свой личный ChatGPT бот в Telegram, при этом мы использовали только serverless-технологии для обработки запросов и ProxyAPI для быстрого и лёгкого доступа к ChatGPT API в России.

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


  1. kknishev
    17.10.2023 15:40
    +1

    починил:
    /deleteWebhook
    /getUpdates?offset=-1
    /setWebhook


  1. MAXH0
    17.10.2023 15:40

    Интересное решение... А добавить логику учета трафика, чтобы неожиданно не влететь на денюшку малую пожалуй стоит.

    Меня не сколько ЖПТ-чаты волнуют, сколько хостинг для локального телеграм бота малых сайтов.


    1. Kenya-West
      17.10.2023 15:40

      У Яндекса нет такой функции, случаем?


  1. An_private
    17.10.2023 15:40

    Почувствовал себя обезьянкой, нажимающей на кнопочки по листочку.

    Посоветуйте - где, что и как проверять если всё вроде сделал по мануалу, а всё равно не работает. У меня есть подозрение, что синтаксис curl в винде несколько не такой, как описано. Но я что мог поправил, получил в ответ: {"ok":true,"result":true,"description":"Webhook was set"} , но всё равно ничего не работает :)


    1. Lexst
      17.10.2023 15:40

      присоединяюсь к запросу :)


    1. fettgesicht Автор
      17.10.2023 15:40

      На Windows формат такой:

      curl ^
        --request POST ^
        --url https://api.telegram.org/bot<токен бота>/setWebhook ^
        --header "content-type: application/json" ^
        --data "{\"url\": \"<домен API-шлюза>\"}"


      1. An_private
        17.10.2023 15:40

        Спасибо. Я просто вбил всё одной строкой и поменял в data одиночные кавычки на двойные - насколько я помню они транслируются в одиночные. Но попробовал и ваш вариант. Получил Webhook is already set. Но всё равно ничего не работает.

        Проверил статус через getWebhookInfo. Всё выглядит правдоподобно:

        "result":{"url":"https://xxx.apigw.yandexcloud.net","has_custom_certificate":false,"pending_update_count":13,"last_error_date":1697555723,"last_error_message":"Wrong response from the webhook: 405 Method Not Allowed","max_connections":40,"ip_address":"xx.xx.xx.xx"}

        Похоже проблема где-то до этого


        1. An_private
          17.10.2023 15:40
          +1

          Всё, с помощью автора разобрался, мой косяк - забыл исправить get на post в конфигурации шлюза. Работает


          1. fettgesicht Автор
            17.10.2023 15:40

            Добавил в статью отдельно спецификацию для шлюза текстом, чтобы можно было просто скопировать


            1. An_private
              17.10.2023 15:40

              Побочный вопрос. В тексте функции я вижу прописана model="gpt-3.5-turbo"

              Есть ли смысла переходить на 4.0 и если есть, то как это сделать - просто поменять имя модели в функции на gpt-4.0 ?


              1. fettgesicht Автор
                17.10.2023 15:40

                Просто "gpt-4". По отзывам модель очень продвинутая, но она и дороже. Для простых вопросов, по-моему, достаточно и 3.5


  1. varvarvar
    17.10.2023 15:40
    +1

    Спасибо за статью! все получилось.


  1. kknishev
    17.10.2023 15:40

    Нужно ли при создании функции привязывать в Параметрах существующий Сервисный аккаунт? На скрине у вас он не выбран и в описании не сказано.

    Попробовал с привязкой и без, всё равно ошибка на следующем шаге с API-шлюзом:
    Invalid openapi spec: Cannot access service account ***: not exists or permission denied


    1. fettgesicht Автор
      17.10.2023 15:40

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


      1. kknishev
        17.10.2023 15:40

        со настройкой шлюза разобрался, был некорректный id

        проблема другая, заработало (чат ответил на /start) и почти сразу перестало :/
        теперь после включения Webhook, идут постоянные запросы с одной и той же ошибкой:
        "last_error_message": "Wrong response from the webhook: 502 Bad Gateway"

        на api-шлюзе 502 также логируется
        proxyapi.ru - отвечает корректно, проверил прямым запросом


        1. fettgesicht Автор
          17.10.2023 15:40

          А что в логах функции?


          1. kknishev
            17.10.2023 15:40

            {"errorMessage": "A request to the Telegram API was unsuccessful. Error code: 400. Description: Bad Request: replied message not found", "errorType": "ApiTelegramException", "stackTrace": [" File \"/function/runtime/runtime.py\", line 212, in handle_event\n result = h(r.event, r.context)\n", " File \"/function/code/index.py\", line 116, in handler\n bot.process_new_updates([update])\n", " File \"/function/code/telebot/__init__.py\", line 740, in process_new_updates\n self.process_new_messages(new_messages)\n", " File \"/function/code/telebot/__init__.py\", line 775, in process_new_messages\n self._notify_command_handlers(self.message_handlers, new_messages, 'message')\n", " File \"/function/code/telebot/__init__.py\", line 6894, in _notify_command_handlers\n self._exec_task(\n", " File \"/function/code/telebot/__init__.py\", line 1198, in _exec_task\n raise e\n", " File \"/function/code/telebot/__init__.py\", line 1191, in _exec_task\n task(*args, **kwargs)\n", " File \"/function/code/telebot/__init__.py\", line 6801, in _run_middlewares_and_handler\n result = handler['function'](message)\n", " File \"/function/code/index.py\", line 44, in send_welcome\n bot.reply_to(\n", " File \"/function/code/telebot/__init__.py\", line 4528, in reply_to\n return self.send_message(message.chat.id, text, reply_to_message_id=message.message_id, **kwargs)\n", " File \"/function/code/telebot/__init__.py\", line 1549, in send_message\n apihelper.send_message(\n", " File \"/function/code/telebot/apihelper.py\", line 264, in send_message\n return _make_request(token, method_url, params=payload, method='post')\n", " File \"/function/code/telebot/apihelper.py\", line 162, in _make_request\n json_result = _check_result(method_name, result)\n", " File \"/function/code/telebot/apihelper.py\", line 189, in _check_result\n raise ApiTelegramException(method_name, result, result_json)\n"]}

            [ERROR] ApiTelegramException: A request to the Telegram API was unsuccessful. Error code: 400. Description: Bad Request: replied message not found Traceback (most recent call last):   File "/function/runtime/runtime.py", line 212, in handle_event     result = h(r.event, r.context)   File "/function/code/index.py", line 116, in handler     bot.process_new_updates([update])   File "/function/code/telebot/__init__.py", line 740, in process_new_updates     self.process_new_messages(new_messages)   File "/function/code/telebot/__init__.py", line 775, in process_new_messages     self._notify_command_handlers(self.message_handlers, new_messages, 'message')   File "/function/code/telebot/__init__.py", line 6894, in _notify_command_handlers     self._exec_task(   File "/function/code/telebot/__init__.py", line 1198, in _exec_task     raise e   File "/function/code/telebot/__init__.py", line 1191, in _exec_task     task(*args, **kwargs)   File "/function/code/telebot/__init__.py", line 6801, in _run_middlewares_and_handler     result = handler['function'](message)   File "/function/code/index.py", line 44, in send_welcome     bot.reply_to(   File "/function/code/telebot/__init__.py", line 4528, in reply_to     return self.send_message(message.chat.id, text, reply_to_message_id=message.message_id, **kwargs)   File "/function/code/telebot/__init__.py", line 1549, in send_message     apihelper.send_message(   File "/function/code/telebot/apihelper.py", line 264, in send_message     return _make_request(token, method_url, params=payload, method='post')   File "/function/code/telebot/apihelper.py", line 162, in _make_request     json_result = _check_result(method_name, result)   File "/function/code/telebot/apihelper.py", line 189, in _check_result     raise ApiTelegramException(method_name, result, result_json)


            1. fettgesicht Автор
              17.10.2023 15:40

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

              Можно попробовать удалить вебхук и снова назначить. Может это обнулит историю.

              curl https://api.telegram.org/bot$BOT_ID/deleteWebhook


              1. kknishev
                17.10.2023 15:40

                удалять Webhook пробовал неоднократно, без результата

                чат не чистил, но предыдущие сообщения точно удалял и возможно, на этом месте и сломалось..


              1. kknishev
                17.10.2023 15:40
                +1

                починил:
                /deleteWebhook
                /getUpdates?offset=-1
                /setWebhook


          1. kknishev
            17.10.2023 15:40

            ERROR RequestID: d80686d5-50f8-40bc-8ec4-cefd6f55163b Code: 502 Message: Error during function invocation