
В большинстве туториалов по 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 и проблем с потоками.
Полезные ссылки:
Пишите код с удовольствием!
