Nyagram Header
Nyagram Header

В большинстве туториалов по 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.

  1. /subscribe -> Старт сессии.

  2. Ввод города -> Сохранение в сессию.

  3. Ввод времени -> Валидация 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);
    }
}

Итог

Мы создали полноценного бота с:

  1. Интеграцией стороннего API (OpenWeatherMap).

  2. Интерактивностью (Инлайн-кнопки с обновлением сообщений).

  3. Диалогами (FSM) с автоматической валидацией данных через аннотации.

  4. Глобальной обработкой ошибок (ControllerAdvice).

  5. Фоновыми задачами (Рассылка по расписанию).

И всё это — с чистым, читаемым кодом благодаря Nyagram. Никаких switch, ручного парсинга JSON и проблем с потоками.

Полезные ссылки:

Пишите код с удовольствием!