Как сделать Telegram-бота умнее: пошаговый гайд на Spring AI и Kotlin
Перед вами ещё один гайд о том, как написать свой telegram-бот, который взаимодействует с нейросетью. Мы напишем его таким образом, чтобы с нейросетью можно было вести диалог, т.е. рассмотрим, как сохранять контекст между сообщениями. Но таких гайдов, особенно для Python, уже написано немало. Поэтому напишем его на новом фреймворке Spring AI из эксосистемы Spring. А чтобы совсем было интересно — писать будем на Kotlin)

Spring AI — это один из фреймворков экосистемы Spring. Его цель — обеспечить при работе с нейросетями принципы проектирования экосистемы Spring, такие как переносимость и модульная конструкция. В Spring AI есть различные провайдеры к популярным нейросетям: DeepSeek, Google Vertex, Groq, Ollama и ChatGPT. Работу с последним мы и рассмотрим в данной статье. Однако надо заметить, что многие нейросети поддерживают унифицированный API, поэтому переход с одной нейросети на другую (например, на DeepSeek) благодаря Spring AI ограничится изменением пары параметров в конфиге.
Делаем заготовку проекта
Итак, зайдём на start.spring.io и создадим заготовку проекта. В качестве языка выбираем Kotlin, тип проекта — Gradle Kotlin. Версию Java можно выбрать 21 как последнюю long-term support на текущий момент.

Далее переходим к зависимостям. Нужный нам стартер называется spring-ai-openai-spring-boot-starter. Он позволяет настраивать подключение к ChatGPT в декларативном стиле. На момент написания статьи он ещё не имеет релизной версии, но скорее всего она появится в ближайшее время.
Также хочу отметить, что вместо spring-ai-openai-spring-boot-starter вы можете выполнять запросы к нейросетям с помощью обычного RestClient (если у вас в зависимостях есть spring-boot-starter-web) или WebClient (для spring-boot-starter-webflux). Просто кода вы напишете чуть больше.
В итоге в секции dependencies в файле build.gradle.kts у вас должны быть следующие зависимости:
implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
implementation("org.telegram:telegrambotsextensions:6.9.0")
Для подключения к ChatGPT нам потребуется Api-Key, который можно сгенерировать в личном кабинете https://platform.openai.com/api-keys. Однако в настоящее время для работы с ChatGPT по API нужно каким-то образом пополнить лицевой счёт и подключить VPN. Последнее, кстати, не обязательно, если вы будете разворачивать ваше приложение у хостера с автоматической поддержкой проксирования запросов. Например, dockhost.ru.
Теперь давайте зарегистрируем нового telegram-бота с помощью BotFather. От него нам надо получить botName (который без постфикса »_bot») и токен.
Все эти параметры надо прописать в настройках приложения в файле application.yml. С точки зрения безопасности все чувствительные данные будем подставлять через переменные окружения.
spring:
ai:
openai:
api-key: ${CHATGPT_API_KEY}
base-url: ${CHATGPT_BASE_URL:https://api.openai.com}
telegram:
token: ${TELEGRAM_BOT_TOKEN}
botName: ${TELEGRAM_BOT_NAME}
Также, если вы подключаетесь через какой-либо прокси-сервис ChatGPT, надо ещё прописать целевой хост в переменной CHATGPT_BASE_URL. Если же подключаетесь напрямую — этот параметр можно вообще не указывать и будет использовано значение по умолчанию.
Сервис для взаимодействия с ChatGPT
Прежде чем написать сервис, следует отметить пару моментов по работе с нейросетью.
В ChatGPT (да и во многих других нейросетях) все сообщения могут быть трёх типов:
system — как правило, первое сообщение, в котором нейросети можно указать предметную область, чтобы повысить качество ответов.
user — сообщения, которые пишет пользователь.
assistant — ответы, которые даёт нейросеть.
spring-ai-openai-spring-boot-starter представляет эти сообщения в виде классов SystemMessage, UserMessage и AssistantMessage с общим интерфейсом Message.
Второй момент заключается в том, что если нейросети отправлять по одному сообщению, то свой ответ она будет формировать только на основании этого сообщения. Так диалог выстроить не удастся. Тогда как ChatGPT помнит весь контекст беседы, когда мы общаемся с ним через веб-интерфейс? Ответ прост. На самом деле достаточно в каждом запросе отправлять всю переписку — тогда контекст будет сохраняться.
Теперь создадим обычный спринговый сервис, который будет обращаться к ChatGPT. В конструктор ему мы будем подставлять OpenAiChatModel — этот бин как раз предназначен для отправки запросов в нейросеть.
@Service
class OpenAiService(
private val chatModel: OpenAiChatModel,
) {
}
Теперь добавим метод sendMessages (), который будет отправлять всю историю нашей переписки в виде списка объектов Message.
fun sendMessages(messages: List): List {
// ...
}
Реализация этого метода довольно проста. Вызываем метод call () у бина chatModel. В параметрах передаём ему объект Prompt, в который подставляем список наших сообщений и с помощью OpenAiChatOptions.builder () указываем ряд параметров запроса:
chatModel.call(
Prompt(
messages,
OpenAiChatOptions.builder()
.model(OpenAiApi.ChatModel.GPT_4_O)
.temperature(0.0)
.responseFormat(ResponseFormat.builder().type(ResponseFormat.Type.TEXT).build())
.build()
)
)
В параметрах мы указываем целевую модель. Разные модели тарифицируются по-разному: более дорогие модели выдают более качественный результат и лучше удерживают общий контекст беседы. В этом примере я использую GPT 4.0.
Второй параметр запроса — «температура», которая имеет тип Double и может принимать значения от 0.0 до 2.0. Если вы хотите, чтобы нейросеть выдавала более креативные ответы — выставляйте температуру побольше. Это может иметь смысл если вы попросите ChatGPT придумать, например, название для нового проекта. Если же хотите получить максимально точный ответ, соответствующий правилам предметной области — ставьте температуру в 0. Это полезно для математических вычислений (хотя точность и не гарантирована на 100%).
Наконец, третий параметр запроса позволяет указать формат ответа. Здесь я указываю формат «простой текст», но можно также попросить выдавать ответы в json.
После вызова метода call () мы получим список объектов типа Generation. Каждый из них содержит поле text — это и есть текстовый ответ нейросети. По умолчанию список будет содержать всегда 1 элемент.
return chatModel.call(
// Prompt
)
.results
?.map {
it.output.text
}
?: emptyList()
Если по каким-то причинам результат мы не получили, то, благодаря элвис-оператору котлина, возвращаем пустой список.
На этом разработка клиентской части ChatGPT завершена.
Компонент для сохранения контекста беседы
Нам нужно где-то сохранять все сообщения текущего диалога, причём отдельно для каждого пользователя telegram.
В идеале нужно создать две таблицы в БД:
dialog, который будет связан с chatId (идентификатор пользователя в Telegram, имеет тип Long)
message, каждая запись в которой будет связана с dialog отношением «один-ко-многим» через поле dialog_id.
Но взаимодействие с БД выходит за рамки данного гайда, поэтому не будем усложнять и обойдёмся без неё.
Вместо этого мы воспользуемся встроенным в Spring механизмом кеширования. В кеше будем накапливать все сообщения в разрезе текущего chatId, а при старте нового диалога будем сбрасывать этот кеш.
Реализация компонента MessageCache довольно тривиальна. Она содержит три метода для чтения, обновления и сброса кеша соответственно:
@Component
class MessageCache {
@Cacheable(cacheNames = ["messages"], key = "#chatId")
fun getOrInitMessages(chatId: Long): List = emptyList()
@CachePut(cacheNames = ["messages"], key = "#chatId")
fun saveMessages(chatId: Long, messages: List): List = messages
@CacheEvict(cacheNames = ["messages"], key = "#chatId")
fun clearMessages(chatId: Long): List = emptyList()
}
Аннотация @Cacheable
содержит параметры cacheNames (имя кеша) и key (ключ, по которому извлекаются данные из кеша). У всех трёх методов одинаковые имя кеша и ключа (chatId).
Метод getOrInitMessages () вызывается в том случае, если в кеше ещё нет сущности с указанным chatId. Это происходит при начале нового диалога, поэтому возвращаем пустой список. Последующие вызовы этого же метода будут возвращать данные из кеша по указанному ключу и реальный вызов метода выполняться не будет.
Аннотация @CachePut
обновляет содержимое кеша. Мы будем передавать туда каждый раз полный актуальный набор сообщений из диалога. И эти же сообщения возвращаем.
Таким образом, поочерёдный вызов методов с аннотациями @Cacheable
и @CachePut
обеспечивает нам накопление сообщений для пользователя.
Аннотация @CacheEvict
удаляет все сообщения с указанным ключом. Метод с такой аннотацией будем вызывать при начале новой беседы с нейросетью.
Команда начала диалога
В telegram можно отправлять чат-боту специальные команды. Это фиксированная строка, которая начинается со слеша. Любой telegram-бот должен поддерживать команду »/start», т.к. именно эта команда автоматически выполняется при первом обращении к боту. Однако никто не мешает вызывать её повторно любое количество раз.
В нашем чат-боте эта команда будет начинать новый диалог. То есть когда захотите поменять тему — просто отправьте чат-боту команду »/start», чтобы он «забыл» всё, о чём вы говорили до этого.
Расширение telegrambotsextensions добавляет к стандартному компоненту возможность добавлять команды независимо друг от друга в виде отдельных бинов. Подтянем в нашу команду бин MessageCache и будем сбрасывать историю сообщений.
@Component
class StartCommand(
private val messageCache: MessageCache,
) : BotCommand("/start", "") {
override fun execute(absSender: AbsSender, user: User, chat: Chat, arguments: Array) {
messageCache.clearMessages(chat.id)
absSender.execute(createMessage(chat.id.toString(), "Начинаем диалог!"))
}
}
При выполнении этой команды telegram-бот отправляет пользователю сообщение «Начинаем диалог!» с помощью absSender.execute (). Создание сообщения происходит через утилитарный метод createMessage ():
fun createMessage(chatId: String, text: String) =
SendMessage(chatId, text)
.apply { enableMarkdown(true) }
.apply { disableWebPagePreview() }
Здесь создаём стандартный объект SendMessage из telegrambots-spring-boot-starter и передаём ему chatId и текст сообщения. Также активируем формат сообщений markdown и отключаем превью гиперссылок (это по желанию, чтобы не загромождать переписку).
Реализация telegram-бота
Взаимодействие с серверами telegram возможно в двух режимах: webhook и long-polling.
В режиме webhook вы разворачиваете полноценное веб-приложение, к которому будет обращаться сам telegram. Однако обязательным требованием является наличие публичного домена и сертификата https.
В случае с long-polling общедоступная точка доступа не требуется, т.к. приложение само делает запрос к серверу telegram и ждёт ответа до тех пор, пока не произойдёт какое-то событие, связанное с ботом (например, получение сообщения от пользователя). В таком режим вы можете поднимать telegram-бота откуда угодно, в том числе локально на вашем устройстве в целях отладки.
С точки зрения простоты мы будем использовать long-polling. Для этого создадим новый сервис и унаследуем его от класса TelegramLongPollingCommandBot. Также в этот сервис подтянем созданные ранее бины OpenAiService, MessageCache и все команды в виде множества Set (у нас там будет только StartCommand). Токен и имя бота подгрузим из конфига с помощью аннотации @Value
.
@Component
class TelegramBot(
private val openAiService: OpenAiService,
private val messageCache: MessageCache,
commands: Set,
@Value("\${telegram.token}")
token: String,
) : TelegramLongPollingCommandBot(token) {
@Value("\${telegram.botName}")
private val botName: String = ""
init {
registerAll(*commands.toTypedArray())
}
override fun getBotUsername(): String = botName
}
В секции init мы регистрируем все подгруженные команды бота с помощью метода registerAll ().
Ну, а теперь осталось определить ключевой метод processNonCommandUpdate () нашего telegram-бота. Он обрабатывает все сообщения, которые «не-команды», т.е. не начинаются со слеша. Метод получает объект Update. Для него мы сначала проверяем, содержит ли он сообщение. Если да — можем извлечь из него chatId отправителя. Далее проверяем, является ли сообщение текстом (а не картинкой, стикером, файлом и т.д.). И после этого мы получаем доступ к тексту сообщения, которое отправил пользователь через telegram-бот.
override fun processNonCommandUpdate(update: Update) {
if (update.hasMessage()) {
val chatId = update.message.chatId
if (update.message.hasText()) {
val messageText = update.message.text
// ...
} else {
execute(createMessage(chatId.toString(), "Я понимаю только текст!"))
}
}
}
При получении сообщения от пользователя мы извлекаем другие сообщения из кеша, чтобы «вспомнить» контекст диалога. При первом обращении диалог будет пустой, т.к. в кеше ничего нет. Добавляем к сообщениям из кеша новое сообщение от пользователя как объект UserMessage. И затем этот список отправляем в ChatGPT.
val messages = messageCache.getOrInitMessages(chatId).toMutableList()
messages += UserMessage(messageText)
val assistantMessages = openAiService.sendMessages(messages)
В ответ мы получаем список сообщений AssistantMessage. В нашем случае там будет всегда один элемент. Добавляем эти сообщения к списку сообщений, обновляем кеш и отправляем пользователю через telegram только последние сообщения от нейросети.
В общем-то это вся бизнес-логика нашего telegram-бота.
Активируем telegram-бот
Чтобы Spring корректно подключился к telegram и зарегистрировал бота при старте приложения, нам нужно ещё добавить небольшую конфигурацию:
@Configuration
class BotConfig {
@Bean
fun telegramBotsApi(bot: TelegramBot): TelegramBotsApi =
TelegramBotsApi(DefaultBotSession::class.java).apply {
registerBot(bot)
}
}
А чтобы включить механизм-кеширования, нужно добавить аннотацию @EnableCaching
к main-классу приложения (который помечен @SpringBootApplication
):
@EnableCaching
@SpringBootApplication
class OpenaiExampleApplication
fun main(args: Array) {
runApplication(*args)
}
Если этого не сделать, ошибки при старте вы не получите, но кеширование работать не будет.
Разворачиваем бота в облаке
В том, чтобы запустить чат-бот локально, никаких проблем не будет. Но давайте рассмотрим, как развернуть наш проект в облаке.
Сперва добавим в build.gradle.kts следующую секцию:
tasks {
bootJar {
archiveFileName.set("openai-bot.jar")
}
}
Это позволит нам получить фиксированное имя jar-файла после компиляции.
Теперь создадим Dockerfile и поместим его в корень проекта.
# Build stage
FROM gradle:jdk21 AS build
WORKDIR /app
COPY . /app
RUN gradle build --no-daemon
# Package stage
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY --from=build /app/build/libs /app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "openai-bot.jar"]
Наличие Dockerfile позволяет выполнять деплой на разных хостингах независимо от технологий виртуализации, которые используются у этого хостера. Но мне, как программисту, хотелось бы развернуть проект прямо из репозитория, подобно тому, как это делается в heroku.
Эта технология называется Push-To-Deploy и она поддерживается в dockhost.ru. Вам достаточно запушить изменения в репозиторий, и всё остальное платформа сделает за вас: выполнит сборку проекта, создаст контейнер и подставит переменные окружения. При необходимости также можно привязать свой домен или сгенерить бесплатный. Https-сертификат при этом будет выпущен автоматически.
Итак, заходим в Панель Управления и создаём новый проект. Указываем для него какое-то понятное имя, напримеp openai-bot. Также при необходимости здесь можно указать другой часовой пояс (по умолчанию UTC).

Теперь перед подключением репозитория надо выставить все переменные окружения. В меню выбираем Окружение — Переменные окружения. И там добавляем все переменные, которые используются в нашем файле application.yml. Напомню, что их минимум 3. При добавлении каждой переменной не забывайте делать её глобальной, отмечая соответствующий чекбокс.

После того как все необходимые переменные выставлены, выбираем пункт меню Репозитории Git.
Здесь указываем произвольное имя для будущего контейнера, а также путь до вашего git-репозитория. Вы можете использовать любой репозиторий: github, gitlab, bitbucket, gitverse и т.п.

Если репозиторий приватный, нужно также указать данные для авторизации. Указываем логин от своей учётки и токен (не пароль!). Токен можно выпустить в самом репозитории.

Также dockhost позволяет настроить лимиты на потребляемые ресурсы, от которых напрямую зависит стоимость хостинга. В нашем случае достаточно минимального резервирования процессора в 5%, а памяти лучше выделить 512 MiB.

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

Здесь очень хорошо демонстрируется сохранение контекста. Кроме того, нейросеть знает правила игры и вежливо поправляет меня, когда я хочу смухлевать.
Помимо игр нейросеть можно также заставить проводить вычисления. Но здесь очень важно выбирать правильные промты и чётко определять нужный формат ответа.

Выводы
Мы убедились, что экосистема Spring уже сейчас позволяет легко интегрироваться с ChatGPT и Telegram. Такая связка из двух популярных платформ открывает вам поистине широкую область для творчества и для решения рутинных задач. Однако стандартный компонент для работы с нейросетями явно не поспевает, т.к. новые нейросети появляются чуть ли не каждый месяц, а компонент до сих пор не имеет стабильной версии. Будем надеяться, в ближайшее время она появится.
Пример проекта доступен на github и полностью готов к деплою. Ещё больше статей и видеогайдов по разработке на Java, Kotlin и Spring вы найдёте на моём сайте devmark.ru.
А если хотите развернуть приложение в облаке буквально в один клик — воспользуйтесь услугами dockhost.ru.