how to connect chat gpt to telegram

Кстати, там выше ☝🏼 есть ссылка на Github, где можно посмотреть код бота. А если лень самому писать бота, можешь пользоваться готовым всего за 1.5 USDT, если интересно - подробности в самом боте https://t.me/broGtpHelper_bot

В этой статье расскажу как можно написать своего Телеграмм бота для общения с Chat GPT, используя Nodejs, Api opentai и Yandex Speech Kit.

C Ботом можно будет поговорить на русском и английскоим, причем он тоже способен отвечать голосом, так что вам будет с кем поговорить по душам 😁, а еще вы сможете добавить персонажей, которых бот должен отыгрывать. Например, если вы выберите программиста, вы также сможете выбрать предпочтительный язык программирования или например сможете выбрать отдельный голос для каждого из персонажей.

Кстати №2, еще я сделал вариант бота с Яндекс GPT, Если интересно, можешь развернуть себе такого прямо из репозитория.

Если интересен процесс написания такого бота, можешь посмотреть видео которые я записал, пока делал бота:

Если видео или код были полезны - ты знаешь что делать ;)

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

Телеграмм бот - очень удобный формат, в отличии от браузера - у меня лично на всех устройствах есть Telegram и я провожу в нем довольно много времени, в том числе и по работе. Поэтому взаимодействие с Chat GPT c помощью бота - очень удобная история лично для метя

Что по стеку?

Бизнес-логика бота

Для написания логики бота буду использовать NodeJS без использования Typescript, просто потому что это быстрее, если проект разрастется, то в будущем его можно переписать на TS, но на начальном этапе лично мне статическая типизация больше мешает, чем помогает.

Чтобы писать бота нам потребуется его API ключ, который вам заботливо выдаст @BotFather (Бот в телеграмме, который создает других ботов)

Openai API

Для взаимодействия с openai будем использовать одноименный npm пакет

Вот список того, что понадобится от openai:

Апишка по моему субьективному мнению довольно бюджетная, за 10 дней использования у меня накапало 0.9$. Отдельно скажу ниже про цены Yandex Speech Kit

Распознавание и синтез речи

Для преобразования текста в голос и обратно будем использовать Yandex Speech Kit:

Для использования также потребуется:

  • привязать карту (в этом случае Российская подойдет)
  • получить OAuth токен от Яндекс.Паспорта . Потом по этому токену мы будем в реалтайме получать и обновлять Iam токен и уже с ним ходить в API за преобразованиеями голоса в текст и обратно
  • в консоли в Object Storage нужно завести бакет (Необязательно, главное условие, чтобы было облако и директория в нем) и скопировать ID его директории, в которой создан бакет (далее будет использован как BUCKET_ID) - насколько я понял этот bucket используется для временного хранения данных Yandex Speech Kit (но у меня он остался пуст возможно его использование происходит где-то за кулисами)

После того, как бакет создан, его ID можно получить из ссылки:

https://console.cloud.yandex.ru/folders/<BUCKET_ID>/storage/buckets/<BUCKET_NAME>

По ценам: за 10 дней использования у меня получилось 49руб. за использование Yandex Speech Kit.

Итого, за 10 дней использования бота, 3мя человеками (Я, жена и мама) я потратил чуть более 100р. Много это или мало - каждый решает сам )

Пишем код

Ниже я буду выделять только важные участки кода, полную версию вы сможете посмотреть на github проекта. Итак, погнали!

Зависимости

"dependencies": {
    "axios": "^1.4.0",
    "dotenv": "^16.0.3",
    "form-data": "^4.0.0",
    "i18next": "^22.4.15",
    "i18next-node-fs-backend": "^2.1.3",
    "openai": "^3.2.1",
    "telegraf": "^4.12.2"
},
  • telegraf - библиотека, которая упрощает написание ботов на NodeJS
  • axios - для выполнения сетевых запросов к API Yandex Speech Kit
  • dotenv - чтобы получать значения токенов и прочих штук, которые не храним под системой контроля версий
  • form-data - пакет понадобится, чтобы сформировать корректные запросы в Yandex Speech Kit
  • openai - пакет для работы с API openai
  • i18next и i18next-node-fs-backend - пакеты для интернационализации, на самом деле чисто опциональная штука, которая не всем нужна. Если ваш бот должен работать в разных регионах с разными языками, то вещь нужная.

Структура проекта

bot structure

Основной код проекта Опишем в src/index.js, в utils будем складывать всякое полезное для работы с апишками Чат Гпт и Яндекс Спич Кита и не только

В файле .env храним секреты (все токены, id бакета) и не коммитим его чтобы ваши токены не оказались в публичном доступе.

Содержимое .env

BOT_API_KEY=<API ключ из @BotFather>
YA_PASSPORT_TOKEN=<https://oauth.yandex.ru/authorize?response_type=token&client_id=1a6990aa636648e9b2ef855fa7bec2fb>
BUCKET_ID=<ID бакета в https://cloud.yandex.ru/folders/<ID>>
GPT_API_KEY=<https://platform.openai.com/account/api-keys>
ACCOUNTS_WHITE_LIST=login1,login2 # Логины для которых должен быть доступен бот, если не указаны, бот будет доступен для всех

Код бота

В самом начале находится import './utils/bootstrap.js'; В этом файле нет ничего, кроме

import dotenv from 'dotenv';
dotenv.config();

Этот импорт должен идти первым, чтобы в process.env в любом другом файле были доступны данные (YA_PASSPORT_TOKEN, BOT_API_KEY и другие).

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


/**
 * Стор, в кототом храним контекст чатов с ботом
 * @type {Map<number, { enableVoiceResponse: boolean; assistantCharacterExtra: Record<string, string>;lang: string; messages: Array<{role: string; content: string}>; assistantCharacter: string }>}
 */
const chatContextStore = new class {
    constructor() {
        this._store = new Map();
    }
    get(id) {
        console.debug('chatContextStore:get:' + id);
        return this._store.get(id);
    }

    set(id, value) {
        console.debug('chatContextStore:set:' + id + ':value:' + JSON.stringify(value, null, 1));
        return this._store.set(id, value);
    }

    has(id) {
        console.debug('chatContextStore:has:' + id);
        return this._store.has(id);
    }
};

Этот стор - обычная мапа, где ключами будут ID чатов, с которыми идет переписка, а значениями - объекты контекста, которые мы закрепили за этими чатами. Я сделал небольшую обертку над джаваскриптовым Map, чтобы логировать события (нормальный логгер к сожалению пока не прикрутил).

Я знаю, что существует готовое решения для сессий в telegraf, но решил использовать свое, считаю, что то что я могу описать в 20 строках не стоит использовать как внешнюю зависимость.

Далее, создаем бота и переопределяем стандарнтый таймаут, потому что chat gpt может отвечать довольно долго и приложение будет отваливаться по таймауту, а оно нам не надо.

/**
 * @type {import('telegraf').Telegram}
 */
const bot = new Telegraf(process.env.BOT_API_KEY, {
    handlerTimeout: 90_000 * 5 // Chat GPT Может отвечать долго, значение по умолчанию 90 сек
});

Дефолтный контекст для каждого пользователя - роль для ассистента по умолчанию Программист, использующий javascript


const initialChatContext = {
    lang: langDefault,
    messages: [],
    assistantCharacter: characterDefault,
    enableVoiceResponse: false,
    assistantCharacterExtra: { language: 'javascript' } // Default language JS ¯\_(ツ)_/¯ 
};

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

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

{
	"characters.programmer.context": "Ты программист, отвечаешь лаконично, стараешься прикладывать ссылки на источники, иногда шутишь странные шутки про код, примеры кода по умолчанию на {{language}}",
    "characters.designer.context": "Ты UX/UI специалист, знаешь все о том, как сделать интерфейс для пользователя понятным и удобным, подходишь к вопросам генерации текстов очень творчески",
    "characters.buddy.context": "Ты приятель, общаешься неформально, при обращении используешь слово Дружище"
}

Я добавил Три контекста: Программист, UX/UI Специалист и Приятель (Спойлер - он самый смешной, если общаться c Chat GPT голосом)

Дальше, немаловажная часть про получение Iam токенов для походов в Yandex Speech Kit API - Эти токены нужно обновлять с какой-то периодичностью, потому что они протухают в течении 12 часов. А для того, чтобы их получить, нужен YA_PASSPORT_TOKEN, который мы получили в начале статьи

/**
 * Получение iam токена https://cloud.yandex.ru/docs/iam/operations/iam-token/create
 * @returns {Promise<{ iamToken: string; expiresAt?: string }>}
 */
export const getIamToken = async () => {
    try {
        const { data } = await axios.post('https://iam.api.cloud.yandex.net/iam/v1/tokens', {
            yandexPassportOauthToken: process.env.YA_PASSPORT_TOKEN
        });
        return data;
    } catch (err) {
        console.error('Error get iam token: ', err?.response?.description || err?.message)
        return { iamToken: null };
    }
}

/**
 * Получаем и обновляем iam токен
 * Концепции https://cloud.yandex.ru/docs/iam/concepts/authorization/iam-token
 * @type{{value: null | string; runUpdates: () => Promise<NodeJS.Timer>}}
 */
export const iamToken = {
    value: null,
    async runUpdates() {
        const { iamToken } = await getIamToken();
        this.value = iamToken;
        const interval = setInterval(async () => {
            const { iamToken: intervalToken } = await getIamToken();
            this.value = intervalToken;
        }, 1000 * 60 * 60); // Раз в час выписываем новый iam токен, потому что он протухает за 12 часов
        return interval;
    }
}

У нас есть объект iamToken, который хранит значение (value) и у которого есть метод runUpdates(), которые получает первый токен, а потом устанавливает интервал, для ежечасного обновления этих токенов. Таким образом у нас всегда есть свеженький Iam токен

Теперь, когда разобрались с тем, как будем получать токен, вернемся в src/index.js. Выписываем токен для конвертации голосовых в текст и только после этого запускаем бота

iamToken.runUpdates() // Выписываем токен для конвертации голосовых в текст и только после этого запускаем бота
    .then(_updateTimer => { // Можно отписаться от интервала обновления токенов 
        runBot()
    });

Собственно, вот что происходит в функции runBot() - Мы навешиваем на созданного бота необходимые обработчики событий и запускаем бота, после чего он готов принимать запросы. Детальнее можете посмотреть тут


const runBot = () => {
	bot.on('callback_query', async (ctx) => {
		// Обрабатываем нажатия кнопок, которые содержат callback_data
	});

	bot.on('message', async (ctx) => {
		if (ctx.message.voice) {
			// Обрабатываем голосовое сообщение
			return;
		}	
		// Обрабатываем текстовое сообщение
	});

	bot.launch();
    console.debug(`✨ Bot started ✨`);
}

Отправляем запросы а API

OpenAI

Код, который отвечает за отправку сообщений Chat GPT сам по себе занимает очень мало строчек кода и находится в src/utils/openai.js. Используем модель chat-3.5-turbo, на момент написания статьи это самая умная и быстрая модель, которая была мне доступна (чтобы получить доступ к chat gpt 4 нужно отстоять очередь в wail list)


import { Configuration, OpenAIApi } from 'openai';

const openai = new OpenAIApi(new Configuration({
    apiKey: process.env.GPT_API_KEY,
}));

/**
 * Выполняет запрос к chat gpt
 * 
 * @param {Array<{ role: 'system' | 'assistant' | 'user'; content: string; }>} messages 
 * @returns {{ choices: { message: string }[], error?: import('axios').AxiosError }}
 */
export const requestAssist = async (messages = []) => {
    try {
        const { data } = await openai.createChatCompletion({
            model: 'gpt-3.5-turbo',
            messages: messages
        });
    
        return data;
    } catch (error) {
        return { choices: [], error: error }
    }
};

Как я и писал выше - на каждый запрос отправляется вся портянка сообщений, от самого первого (которое отправляем всегда, чтобы преднастроить бота и сказать ему какую роль он отыгрывает { role: 'system', content: 'Описание того, как должен себя вести бот' } ) до самого последнего.

Всего ролей три:

  • system - про это я писал выше
  • user - сообщения от пользователя
  • assistant - сообщения от ассистента (тобишь от Chat GPT)

Yandex Speech Kit

Yandex Speech Kit API используется, как я и писал выше, для преобразования голосовых сообщений в текст, а также для преобразования текстовых сообщений от Chat GPT в голос, для этих целей в src/utils/yandex.js есть соотвествующие фукнции:

Распознавание голосовых сообщений

Вся логика распознавания речи расположена в двух файлах:

  • В src/index.js - мы навешиваем обработчик на событие message и если в пришедшем объекте видим наличие ctx.message.voice - понимаем, что пользователь оставил голосовое сообщение. Ссылка на файл тоже доступна в ctx.message.voice.file_id - с помощью axios мы получим содержимое файла в виде arraybuffer
  • Далее мы скармливаем эту последовательность байтов в том же виде в Yandex Speech Kit (в фукнцию recognizeVoice) - без приседаний с установкой ffmpeg, конрвертации в mp3 и промежуточного сохранения файла (такое я видел на одном из каналов Youtube). Тут из параметров нужно передать folderId (BUCKET_ID), lang (Язык, на котором говорит пользователь) и значение IAM токена
  • В результате получаем prompt - то есть текстовое представление голосового сообщение, которое можем смело отправлять в openai API
// src/index.js
 bot.on('message', async (ctx) => {
        if (ctx.message.voice) { // Voice messages handling
            const { voice, from } = ctx.message;
            const { id } = from;
            const { file_id } = voice;
            
            ctx.telegram.getFileLink(file_id).then(async (fileLink) => {
                // Получаем ссыль на голосовое сообщение
                const { href } = fileLink;
				const { data: voiceBuffer } = await axios.get(href, { responseType: 'arraybuffer' });
				const prompt = await recognizeVoice(voiceBuffer, i18next.language);
			});
		}
});

/**
 * Распознавание речи через Yandex Speech Kit
 * https://cloud.yandex.ru/docs/speechkit/quickstart
 * @param {ArrayBuffer} buffer голосовое сообщение в виде ArrayBuffer
 * @param {'ru' | 'en'} lang 
 * @returns {string}
 */
export const recognizeVoice = async (buffer, lang = 'ru-RU') => {
    const response = await axios({
        method: 'post',
        url: `https://stt.api.cloud.yandex.net/speech/v1/stt:recognize?folderId=${process.env.BUCKET_ID}&lang=${lang}`,
        headers: {
          Authorization: `Bearer ${iamToken.value}`,
          'Content-Type': 'application/octet-stream'
        },
        data: buffer
      });
    return response.data?.result || '' ;
};

Синтез речи из текстовых сообщений Chat GPT

С синтезом речи все немного сложнее, есть несколько моментов, которые нужно учесть и на которые я напоролся:

  • Под разные языки пользователя доступны разные голоса в Yandex Speech Kit - причем если подобрать не правильно, валится малопонятная ошибка о том что для Языка Ru доступнен только голос filipp . Но это полная хрень, потому что для Русского языка доступно больше голосов и они все работают, но эта же ошибка валится когда я передаю язык en . Поэтому первый вывод к которому я пришел - лучше передавать полную локаль пользователя (не ru, а ru-RU, не en, а en-EN), тогда все работает.
  • В документации совсем нет примеров на nodejs, пришлось поковыряться, чтобы подобрать нужные параметры запроса (как я и сказал, ошибки, которые в итоге отдает в этом API Яндекс - малопонятная и бесполезная история, которая скорее мешает и сбивает с толку, чем помогает). В итоге сработало использование пакета form-data, чтобы правильно передать данные и нужные заголовки
  • В итоге функция, которую я назвал vocalizeVoice выглядит так:


export const vocalizeText = async (text, lang, character) => {
    const formData = new FormData();
    // https://cloud.yandex.com/en/docs/speechkit/tts/voices
    let voice;
    let emotion;

    switch (lang) {
        case 'en-EN':
            voice = 'john';
            break;
        case 'ru-RU':
            if (character === characters.buddy) {
                emotion = 'good';
                voice = 'ermil';
            } else {
                emotion = 'good';
                voice = 'alena';
            }
            break;
    }

    if (emotion)
        formData.append('emotion', emotion);

    formData.append('text', text);
    formData.append('lang', lang);
    formData.append('voice', voice);
    formData.append('folderId', process.env.BUCKET_ID);

    const headers = {
        Authorization: `Bearer ${iamToken.value}`,
        ...formData.getHeaders()
    };

    const response = await axios.post('https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize', formData, {
        headers,
        responseType: 'arraybuffer',
    });

    return response.data; 
}

Для Английского языка я передаю параметр voice: 'john', а для русского меняю голос в зависимости от текущей роли ассистента, если выбран приятель, то ermil, иначе alena, вы тут можете поэкспериментировать и настроить все на свой лад.

Но есть еще один нюанс, о котором стоит сказать, я не просто скармливаю текст от chat gpt в speech/v1/tts:synthesize , а предварительно обрабатываю его вытаскивая участки кода, потому что когда синтезированный голос пытается зачитать участок кода - льется кровь из ушей, поэтому участки с кодом падают в чат в виде кода в markdown формате, который можно скопировать, отправка таких обработанных сообщений происходит тут, а непосредственно сама обработка с вытаскиванием кода - тут

Заключение

chat gpt bot voice response

Как и обещал - я рассказал о том, что нужно, чтобы запустить своего собственного Chat GPT ассистента на базе бота в Telegram. Вы можете сделать это, просто пройдя по инструкции в Репозитории проекта

Надеюсь, эта статья поможет кому-нибудь и сэкономит немного времени!