
В большинстве туториалов по Java-ботам нам предлагают написать EchoBot или магазин пиццы на огромных switch-case конструкциях. Но в 2026 году, когда у нас есть Java 21 и Spring Boot 3, писать бойлерплейт — это преступление против продуктивности.
Сегодня мы напишем WeatherBot — полезного бота, который показывает реальную погоду через API OpenWeatherMap, имеет кнопку обновления "на лету" и, используя FSM (Машину состояний) с валидацией данных, позволяет настроить ежедневную рассылку прогноза.
Мы будем использовать фреймворк Nyagram v1.1.1. Он берет на себя всю грязную работу: парсинг аргументов, валидацию ввода, обработку кнопок и хранение состояния диалогов.
📚 Документация: nyagram.kaleert.pro
🐙 Исходный код либы: GitHub
1. Настройка проекта
Создаем Spring Boot проект (Java 21+) и добавляем зависимости. Мы будем использовать H2 (in-memory базу) для хранения подписок, чтобы пример можно было запустить сразу.
build.gradle
plugins { id 'java' id 'org.springframework.boot' version '3.2.0' id 'io.spring.dependency-management' version '1.1.4' } group = 'com.example' version = '1.0.0' java { sourceCompatibility = '21' } repositories { mavenCentral() } dependencies { // Основная библиотека Nyagram (последняя версия с валидацией) implementation 'io.github.kaleert:nyagram:1.1.1' // Для выполнения HTTP-запросов к API погоды implementation 'org.springframework.boot:spring-boot-starter-web' // База данных (JPA + H2) implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.h2database:h2' // Удобства compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' }
application.yml
Здесь мы настраиваем бота и базу данных.
Внимание: Замените YOUR_BOT_TOKEN и OPENWEATHER_API_KEY на свои ключи.
nyagram: bot-token: "YOUR_BOT_TOKEN_HERE" bot-username: "MyWeatherBot" mode: POLLING worker-thread-count: 10 # Используем виртуальные потоки под капотом # Настройки FSM (диалогов) state-repository: type: memory fsm: ttl-minutes: 60 # Диалог сбросится, если юзер молчит час # Наш кастомный конфиг для погоды weather: api-key: "YOUR_OPENWEATHER_API_KEY" spring: datasource: url: jdbc:h2:mem:weatherdb driverClassName: org.h2.Driver username: sa password: jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: update
2. База данных и Сервис Погоды
Сначала напишем бизнес-логику, не связанную с телеграмом.
Сущность Subscription.java
Хранит информацию о том, кому и во сколько отправлять погоду.
package com.example.weatherbot.entity; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; @Entity @Table(name = "subscriptions") @Data @NoArgsConstructor @AllArgsConstructor public class Subscription { @Id private Long chatId; private String city; private String notificationTime; // Формат "HH:mm" }
Репозиторий SubscriptionRepository.java
package com.example.weatherbot.repository; import com.example.weatherbot.entity.Subscription; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface SubscriptionRepository extends JpaRepository<Subscription, Long> { List<Subscription> findAllByNotificationTime(String time); }
Сервис WeatherService.java
Реальный запрос к API OpenWeatherMap. Используем TextUtil из библиотеки для безопасного экранирования HTML.
package com.example.weatherbot.service; import com.fasterxml.jackson.databind.JsonNode; import com.kaleert.nyagram.util.TextUtil; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @Service @RequiredArgsConstructor public class WeatherService { @Value("${weather.api-key}") private String apiKey; private final RestTemplate restTemplate = new RestTemplate(); public String getWeather(String city) { String url = String.format( "https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=metric&lang=ru", city, apiKey ); try { JsonNode response = restTemplate.getForObject(url, JsonNode.class); String desc = response.path("weather").get(0).path("description").asText(); double temp = response.path("main").path("temp").asDouble(); double feelsLike = response.path("main").path("feels_like").asDouble(); int humidity = response.path("main").path("humidity").asInt(); return String.format( "🌤 <b>Погода в %s</b>:\n" + "%s, температура: <b>%.1f°C</b>\n" + "(ощущается как %.1f°C)\n" + "💧 Влажность: %d%%", TextUtil.escapeHtml(city), desc, temp, feelsLike, humidity ); } catch (Exception e) { return "❌ Не удалось найти город <b>" + TextUtil.escapeHtml(city) + "</b>. Проверьте название."; } } }
3. Команды и Умные аргументы
В Nyagram не нужно парсить текст руками. Аннотация @CommandArgument сделает это за вас. Также мы используем InlineKeyboardBuilder для красивого создания кнопок.
WeatherCommand.java
package com.example.weatherbot.command; import com.example.weatherbot.service.WeatherService; import com.kaleert.nyagram.api.objects.replykeyboard.InlineKeyboardMarkup; import com.kaleert.nyagram.command.BotCommand; import com.kaleert.nyagram.command.CommandArgument; import com.kaleert.nyagram.command.CommandContext; import com.kaleert.nyagram.command.CommandHandler; import com.kaleert.nyagram.util.keyboard.InlineKeyboardBuilder; import lombok.RequiredArgsConstructor; @BotCommand(value = "/weather", description = "Узнать погоду") @RequiredArgsConstructor public class WeatherCommand { private final WeatherService weatherService; // Алиасы позволяют вызывать команду словом "погода" @CommandHandler(value = "/weather", aliases = {"погода"}) public void handle(CommandContext ctx, // Nyagram сама достанет город из текста "/weather London" // required = false позволяет нам самим обработать отсутствие аргумента @CommandArgument(value = "city", required = false) String city) { if (city == null || city.isBlank()) { ctx.reply("ℹ️ Использование: <code>/weather Лондон</code> или <code>погода Москва</code>"); return; } String forecast = weatherService.getWeather(city); // Строим клавиатуру с кнопкой обновления // В callback data зашиваем город: weather:refresh:{city} InlineKeyboardMarkup keyboard = InlineKeyboardBuilder.create() .button("🔄 Обновить", "weather:refresh:" + city) .build(); ctx.reply(forecast, "HTML", null, keyboard); } }
4. Callbacks: Обновление сообщения
Теперь обработаем нажатие кнопки "Обновить". Мы используем Path Variables в аннотации @Callback, чтобы не парсить строку с двоеточиями вручную.
CallbackHandler.java
package com.example.weatherbot.handler; import com.example.weatherbot.service.WeatherService; import com.kaleert.nyagram.api.methods.updatingmessages.EditMessageText; import com.kaleert.nyagram.callback.annotation.Callback; import com.kaleert.nyagram.callback.annotation.CallbackVar; import com.kaleert.nyagram.command.CommandContext; import com.kaleert.nyagram.api.objects.replykeyboard.InlineKeyboardMarkup; import com.kaleert.nyagram.util.keyboard.InlineKeyboardBuilder; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.time.LocalTime; import java.time.format.DateTimeFormatter; @Component @RequiredArgsConstructor public class CallbackHandler { private final WeatherService weatherService; // Автоматически извлекаем {city} из data кнопки "weather:refresh:London" @Callback("weather:refresh:{city}") public void onRefresh(CommandContext ctx, @CallbackVar("city") String city) { String newForecast = weatherService.getWeather(city) + "\n\n<i>Обновлено: " + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "</i>"; InlineKeyboardMarkup keyboard = InlineKeyboardBuilder.create() .button("🔄 Обновить", "weather:refresh:" + city) .build(); // Редактируем сообщение через встроенный клиент try { ctx.getClient().execute(EditMessageText.builder() .chatId(ctx.getChatId().toString()) .messageId(ctx.getMessage().get().getMessageId().intValue()) .text(newForecast) .parseMode("HTML") .replyMarkup(keyboard) .build()); } catch (Exception e) { // Игнорируем ошибку, если текст не изменился (Telegram API limitation) } // Nyagram сама отправит answerCallbackQuery, чтобы убрать часики загрузки } }
5. FSM и Декларативная Валидация
Реализуем диалог подписки. Главная фишка версии 1.1.1 — автоматическая валидация. Нам не нужно писать if (!time.matches(...)). Мы просто вешаем аннотацию @Validation.
/subscribe-> Старт сессии.Ввод города -> Сохранение в сессию.
Ввод времени -> Валидация Regex -> Сохранение в БД.
SubscribeFlow.java
package com.example.weatherbot.flow; import com.example.weatherbot.entity.Subscription; import com.example.weatherbot.repository.SubscriptionRepository; import com.kaleert.nyagram.command.BotCommand; import com.kaleert.nyagram.command.CommandContext; import com.kaleert.nyagram.command.CommandHandler; import com.kaleert.nyagram.fsm.SessionManager; import com.kaleert.nyagram.fsm.UserSession; import com.kaleert.nyagram.fsm.annotation.SessionData; import com.kaleert.nyagram.fsm.annotation.StateAction; import com.kaleert.nyagram.validation.Validation; // <-- Новая фича! import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component @BotCommand(value = "/subscribe", description = "Подписаться на рассылку") @RequiredArgsConstructor public class SubscribeFlow { private final SessionManager sessionManager; private final SubscriptionRepository repository; public static final String WAITING_CITY = "SUB:WAIT_CITY"; public static final String WAITING_TIME = "SUB:WAIT_TIME"; // 1. Старт диалога @CommandHandler(aliases = {"📝 Подписаться"}) public void start(CommandContext ctx) { sessionManager.startSession(ctx.getUserId(), ctx.getChatId(), WAITING_CITY); ctx.reply("📝 Настройка рассылки.\nВведите название <b>города</b>:", "HTML"); } // 2. Ввод города @StateAction(WAITING_CITY) public void onCityInput(CommandContext ctx, UserSession session) { String city = ctx.getText(); session.putData("city", city); // Кладем в кэш сессии sessionManager.updateState(ctx.getUserId(), WAITING_TIME); ctx.reply("Город: <b>" + city + "</b>.\nТеперь введите время (формат HH:mm):"); } // 3. Ввод времени с валидацией // Если ввод не соответствует паттерну, метод НЕ вызовется, а вылетит исключение ArgumentParseException @StateAction(value = WAITING_TIME, clearAfter = true) public void onTimeInput( CommandContext ctx, @SessionData("city") String city, // Магия: достаем город из сессии аргументом // Декларативная валидация формата времени @Validation(pattern = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$") String time ) { Subscription sub = new Subscription(ctx.getChatId(), city, time); repository.save(sub); ctx.reply("✅ Подписка оформлена! Ждите прогноз в " + time); } }
6. Глобальная обработка ошибок
Что произойдет, если пользователь введет "полвторого" вместо "13:30"? Сработает валидатор и выбросит исключение. Чтобы не крашить бота и красиво ответить юзеру, используем @BotControllerAdvice — аналог ControllerAdvice из Spring Web.
GlobalErrorHandler.java
package com.example.weatherbot.config; import com.kaleert.nyagram.command.CommandContext; import com.kaleert.nyagram.exception.ArgumentParseException; import com.kaleert.nyagram.exception.BotControllerAdvice; import com.kaleert.nyagram.exception.BotExceptionHandler; @BotControllerAdvice public class GlobalErrorHandler { // Перехватываем ошибки валидации (из команд и FSM) @BotExceptionHandler(ArgumentParseException.class) public void handleValidation(ArgumentParseException e, CommandContext ctx) { // Отправляем понятное сообщение пользователю // При этом состояние FSM не сбрасывается, юзер может повторить ввод ctx.reply("⚠️ <b>Ошибка ввода:</b> " + e.getMessage() + "\nПопробуйте еще раз.", "HTML"); } @BotExceptionHandler(Exception.class) public void handleGeneric(Exception e, CommandContext ctx) { ctx.reply("❌ Произошла непредвиденная ошибка."); e.printStackTrace(); // Логируем } }
7. Планировщик (Scheduler)
Фоновая задача на Spring, которая рассылает сообщения. Используем NyagramClient для асинхронной отправки.
NotificationScheduler.java
package com.example.weatherbot.scheduler; import com.example.weatherbot.entity.Subscription; import com.example.weatherbot.repository.SubscriptionRepository; import com.example.weatherbot.service.WeatherService; import com.kaleert.nyagram.client.NyagramClient; import com.kaleert.nyagram.api.methods.send.SendMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.List; @Slf4j @Component @RequiredArgsConstructor public class NotificationScheduler { private final SubscriptionRepository repository; private final WeatherService weatherService; private final NyagramClient botClient; @Scheduled(cron = "0 * * * * *") // Каждую минуту public void sendNotifications() { String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")); List<Subscription> subs = repository.findAllByNotificationTime(currentTime); if (!subs.isEmpty()) { log.info("Рассылка: найдено {} подписок для времени {}", subs.size(), currentTime); } for (Subscription sub : subs) { // Выполняем асинхронно, чтобы не блокировать шедулер try { String forecast = weatherService.getWeather(sub.getCity()); botClient.executeAsync(SendMessage.builder() .chatId(sub.getChatId().toString()) .text("⏰ <b>Ежедневный прогноз:</b>\n" + forecast) .parseMode("HTML") .build()); } catch (Exception e) { log.error("Ошибка отправки пользователю {}", sub.getChatId(), e); } } } }
Не забудьте включить шедулинг в главном классе:
@SpringBootApplication @EnableScheduling // <-- Важно! public class WeatherBotApplication { public static void main(String[] args) { SpringApplication.run(WeatherBotApplication.class, args); } }
Итог
Мы создали полноценного бота с:
Интеграцией стороннего API (OpenWeatherMap).
Интерактивностью (Инлайн-кнопки с обновлением сообщений).
Диалогами (FSM) с автоматической валидацией данных через аннотации.
Глобальной обработкой ошибок (ControllerAdvice).
Фоновыми задачами (Рассылка по расписанию).
И всё это — с чистым, читаемым кодом благодаря Nyagram. Никаких switch, ручного парсинга JSON и проблем с потоками.
Полезные ссылки:
Пишите код с удовольствием!