Как написать Telegram-бота на Java: токен, TelegramBots, команды и авторизация

Как написать Telegram-бота на Java: токен, TelegramBots, команды и авторизация Полезное

Telegram-бот на Java — это обычная Java-программа, которая по HTTPS забирает у Telegram Bot API новые события (сообщения, нажатия кнопок) и отвечает на них вызовами того же API. Слово «авторизация» в этой теме означает три разные вещи, и их важно не путать: бот доказывает Telegram, что он — это он (токен бота); бот решает, каким пользователям отвечать (проверка user id); сайт проверяет, что пользователь действительно вошел через Telegram (подпись Login Widget).

Ниже — все три по порядку на одном проекте: токен в BotFather, Maven-проект на библиотеке TelegramBots, бот с командами и списком разрешенных пользователей, затем проверка входа через Telegram на Java. Код проверен 24.09.2026 на Java 25 (Temurin 25.0.4) и TelegramBots 10.3.0; обмен с Bot API прогнан на локальном тестовом сервере, который отвечает в формате Telegram.

Мини-словарь

Термин Что это Где встречается в коде
Токен бота Секрет вида 123456:ABC..., выдается BotFather; кто знает токен, тот управляет ботом переменная окружения TELEGRAM_BOT_TOKEN
Update Одно событие для бота: новое сообщение, редактирование, нажатие кнопки метод consume(Update)
user id и chat id id человека и id чата; в личке совпадают, в группе chat id — это id группы getFrom().getId() и getChatId()
Long polling и webhook Бот сам спрашивает Telegram о новых событиях (getUpdates) или Telegram присылает их на ваш HTTPS-адрес TelegramBotsLongPollingApplication

Шаг 1. Получить токен в BotFather

  1. Откройте официальный @BotFather (с синей галочкой) и отправьте /newbot.
  2. Введите отображаемое имя, затем уникальный username, который заканчивается на bot, например my_otus_bot.
  3. BotFather пришлет токен. Храните его в менеджере паролей, не в коде и не в Git.

Если токен утек, отзовите его командой /revoke: старый перестанет работать, бот получит новый. Командой /setcommands задается меню команд у поля ввода.

Шаг 2. Maven-проект

С версии 7 TelegramBots разбита на модули: telegrambots-longpolling принимает обновления, telegrambots-client отправляет запросы. Учебники с TelegramLongPollingBot и getBotToken() написаны под старые версии и с актуальной не соберутся.

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>ru.example</groupId>
  <artifactId>otus-bot</artifactId>
  <version>1.0</version>
  <properties>
    <maven.compiler.release>25</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <telegrambots.version>10.3.0</telegrambots.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.telegram</groupId>
      <artifactId>telegrambots-longpolling</artifactId>
      <version>${telegrambots.version}</version>
    </dependency>
    <dependency>
      <groupId>org.telegram</groupId>
      <artifactId>telegrambots-client</artifactId>
      <version>${telegrambots.version}</version>
    </dependency>
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-simple</artifactId>
      <version>2.0.20</version>
    </dependency>
  </dependencies>
</project>

slf4j-simple не обязателен, но без него библиотека молчит о своих ошибках и при старте печатает предупреждение No SLF4J providers were found.

Шаг 3. Бот с командами и списком доступа

Сначала весь класс целиком, затем разбор. Файл src/main/java/ru/example/bot/CommandBot.java:

package ru.example.bot;

import java.util.Locale;
import java.util.Set;

import org.telegram.telegrambots.longpolling.util.LongPollingSingleThreadUpdateConsumer;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.message.Message;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import org.telegram.telegrambots.meta.generics.TelegramClient;

public class CommandBot implements LongPollingSingleThreadUpdateConsumer {

    private final TelegramClient telegram;
    private final String botUsername;       // без @, например my_otus_bot
    private final Set<Long> allowedUserIds; // кому бот отвечает

    public CommandBot(TelegramClient telegram, String botUsername, Set<Long> allowedUserIds) {
        this.telegram = telegram;
        this.botUsername = botUsername.toLowerCase(Locale.ROOT);
        this.allowedUserIds = Set.copyOf(allowedUserIds);
    }

    @Override
    public void consume(Update update) {
        // Нас интересуют только текстовые сообщения; стикеры, фото и т.п. пропускаем
        if (!update.hasMessage() || !update.getMessage().hasText()) {
            return;
        }
        Message message = update.getMessage();
        boolean privateChat = message.isUserMessage();
        // В группе реагируем только на команды, обычную переписку не трогаем
        if (!privateChat && !message.getText().startsWith("/")) {
            return;
        }
        Long userId = message.getFrom() == null ? null : message.getFrom().getId();

        String reply;
        if (userId == null || !allowedUserIds.contains(userId)) {
            // Подсказка с id - только в личке, в группе чужим молчим
            reply = privateChat ? "Доступ закрыт. Ваш Telegram id: " + userId : null;
        } else {
            reply = handle(message.getText());
        }
        if (reply != null) {
            send(message.getChatId(), reply);
        }
    }

    String handle(String text) {
        String[] parts = text.strip().split("\\s+", 2);
        String command = parts[0].toLowerCase(Locale.ROOT);
        String argument = parts.length > 1 ? parts[1] : "";

        // В группах команда может прийти как /help@имя_бота
        int at = command.indexOf('@');
        if (at >= 0) {
            if (!command.substring(at + 1).equals(botUsername)) {
                return null; // команда адресована другому боту
            }
            command = command.substring(0, at);
        }

        return switch (command) {
            case "/start" -> "Привет! Я учебный бот на Java. Команды: /help, /echo <текст>";
            case "/help" -> "/start - приветствие\n/echo <текст> - повторю текст";
            case "/echo" -> argument.isEmpty() ? "Напишите текст после /echo" : argument;
            default -> command.startsWith("/")
                    ? "Не знаю команду " + command + ". Список: /help"
                    : "Я понимаю только команды. Список: /help";
        };
    }

    private void send(long chatId, String text) {
        SendMessage request = SendMessage.builder()
                .chatId(chatId)
                .text(text)
                .build();
        try {
            telegram.execute(request);
        } catch (TelegramApiException e) {
            System.err.println("Не удалось отправить сообщение: " + e.getMessage());
        }
    }
}

Точка входа BotApp.java читает секреты из переменных окружения:

package ru.example.bot;

import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;

import org.telegram.telegrambots.client.okhttp.OkHttpTelegramClient;
import org.telegram.telegrambots.longpolling.TelegramBotsLongPollingApplication;

public class BotApp {

    public static void main(String[] args) throws Exception {
        String token = requireEnv("TELEGRAM_BOT_TOKEN");
        String username = requireEnv("TELEGRAM_BOT_USERNAME");
        Set<Long> allowed = parseIds(System.getenv().getOrDefault("ALLOWED_USER_IDS", ""));

        var telegram = new OkHttpTelegramClient(token);
        try (var app = new TelegramBotsLongPollingApplication()) {
            app.registerBot(token, new CommandBot(telegram, username, allowed));
            System.out.println("Бот запущен, разрешенных пользователей: " + allowed.size());
            Thread.currentThread().join(); // работаем до Ctrl+C
        }
    }

    private static String requireEnv(String name) {
        String value = System.getenv(name);
        if (value == null || value.isBlank()) {
            throw new IllegalStateException("Не задана переменная окружения " + name);
        }
        return value.strip();
    }

    static Set<Long> parseIds(String csv) {
        return Arrays.stream(csv.split(","))
                .map(String::strip)
                .filter(s -> !s.isEmpty())
                .map(Long::parseLong)
                .collect(Collectors.toUnmodifiableSet());
    }
}

Запуск (macOS/Linux, bash или zsh; exec-плагин Maven подтянется при первом вызове):

export TELEGRAM_BOT_TOKEN='сюда-токен-от-BotFather'
export TELEGRAM_BOT_USERNAME='my_otus_bot'
export ALLOWED_USER_IDS='111'
mvn -q compile exec:java -Dexec.mainClass=ru.example.bot.BotApp

Отправьте боту в личке /start. Если вашего id нет в ALLOWED_USER_IDS, бот ответит «Доступ закрыт» и назовет id — впишите его в переменную и перезапустите. Журнал прогона на локальном тестовом сервере Bot API:

getUpdates offset=1 -> 7
sendMessage chat_id=111 text='Привет! Я учебный бот на Java. Команды: /help, /echo <текст>'
sendMessage chat_id=111 text='привет, бот'
sendMessage chat_id=111 text='Не знаю команду /weather. Список: /help'
sendMessage chat_id=111 text='/start - приветствие\n/echo <текст> - повторю текст'
sendMessage chat_id=999 text='Доступ закрыт. Ваш Telegram id: 999'
getUpdates offset=8 -> 0

Входящие: /start, /echo привет, бот, /weather, /help@other_bot, /HELP@My_Otus_Bot, стикер и /start в личке от чужого пользователя 999. Команда для другого бота и стикер остались без ответа — так и задумано. В группе бот так же молчит на обычные сообщения и на команды посторонних: иначе он отвечал бы «Доступ закрыт» на каждую реплику участников.

Как это работает

  • Регистрация. registerBot сначала вызывает deleteWebhook (в прогоне это первый запрос), потому что long polling и webhook у одного бота одновременно не работают.
  • Цикл опроса. Библиотека вызывает getUpdates с параметром offset. После обработки событий 1-7 следующий запрос идет с offset=8: так Telegram понимает, что события до 7 включительно доставлены. Неподтвержденные события Telegram хранит ограниченное время (по документации Bot API — до 24 часов).
  • consume(Update) вызывается для событий по очереди, в одном потоке: медленная операция внутри задерживает все следующие ответы.
  • handle(String) отделяет команду от аргумента, приводит к нижнему регистру и снимает суффикс @имя_бота. Сети он не касается и легко тестируется.

Частая ошибка: сравнивать команду через equals

public class NaiveCommands {
    static String handle(String text) {
        if (text.equals("/start")) return "Привет!";
        return "Не знаю команду";
    }

    public static void main(String[] args) {
        for (String text : new String[] {"/start", "/start@my_otus_bot", "/Start", "/start "}) {
            System.out.println(text + " -> " + handle(text));
        }
    }
}

Результат:

/start -> Привет!
/start@my_otus_bot -> Не знаю команду
/Start -> Не знаю команду
/start  -> Не знаю команду

В группах клиент добавляет к команде @имя_бота, а пользователь может набрать регистр или пробел иначе. Исправление — разбор из handle(): на те же четыре входа он отвечает приветствием, а /start@other_bot игнорирует (возвращает null).

Авторизация пользователей в боте

Бот открыт всем, кто найдет его по username. Если он управляет чем-то внутренним (сервер, заказы, отчеты), доступ ограничивают по user id: именно getFrom().getId(), а не username (его можно сменить) и не chat id (в группе он общий для всех участников). Для десятков пользователей и ролей список переносят в БД, а подсказку «Доступ закрыт» с id в рабочем боте убирают.

Вход на сайт через Telegram: проверка подписи на Java

Для входа на сайт служит Telegram Login Widget: пользователь нажимает кнопку, а сервер получает поля id, first_name, username, auth_date и hash. Верить им можно только после проверки подписи, иначе любой подставит чужой id. Алгоритм из документации Telegram: все поля, кроме hash, по алфавиту в виде ключ=значение через перевод строки; ключ = SHA-256 от токена бота; HMAC-SHA-256 этой строки сравнить с hash.

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class TelegramLoginCheck {

    static boolean isValid(Map<String, String> fields, String botToken,
                           Instant now, Duration maxAge) throws Exception {
        String receivedHash = fields.get("hash");
        String authDate = fields.get("auth_date");
        if (receivedHash == null || authDate == null) {
            return false;
        }

        // 1. data-check-string: все поля, кроме hash, по алфавиту, через \n
        String dataCheckString = new TreeMap<>(fields).entrySet().stream()
                .filter(e -> !e.getKey().equals("hash"))
                .map(e -> e.getKey() + "=" + e.getValue())
                .collect(Collectors.joining("\n"));

        // 2. ключ = SHA-256 от токена бота, подпись = HMAC-SHA-256
        byte[] secretKey = MessageDigest.getInstance("SHA-256")
                .digest(botToken.getBytes(StandardCharsets.UTF_8));
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
        byte[] expected = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8));

        // 3. сравнение за постоянное время + проверка свежести
        try {
            byte[] received = HexFormat.of().parseHex(receivedHash);
            long age = now.getEpochSecond() - Long.parseLong(authDate);
            return MessageDigest.isEqual(expected, received)
                    && age >= 0 && age <= maxAge.toSeconds();
        } catch (IllegalArgumentException e) { // не hex или не число
            return false;
        }
    }

    public static void main(String[] args) throws Exception {
        String token = "123456:TEST-TOKEN"; // учебный, не настоящий
        Map<String, String> fromWidget = Map.of(
                "id", "111",
                "first_name", "Anna",
                "username", "anna_dev",
                "auth_date", "1790000000",
                "hash", "50cb27bb97d52e33c86e9df76e9dc745c10dd05cd4c40ef9158ad7dc05d4c7fa");
        Instant now = Instant.ofEpochSecond(1790000000L + 600); // через 10 минут
        Duration maxAge = Duration.ofDays(1);

        System.out.println("подлинные данные: " + isValid(fromWidget, token, now, maxAge));

        var forged = new TreeMap<>(fromWidget);
        forged.put("id", "222"); // подменили id пользователя
        System.out.println("подмененный id:   " + isValid(forged, token, now, maxAge));

        Instant later = now.plus(Duration.ofDays(2));
        System.out.println("через 2 дня:      " + isValid(fromWidget, token, later, maxAge));

        var broken = new TreeMap<>(fromWidget);
        broken.put("hash", "not-hex");
        System.out.println("мусор в hash:     " + isValid(broken, token, now, maxAge));
    }
}

Запуск без Maven, одним файлом: java TelegramLoginCheck.java. Результат:

подлинные данные: true
подмененный id:   false
через 2 дня:      false
мусор в hash:     false

MessageDigest.isEqual сравнивает за постоянное время, проверка auth_date не дает бесконечно переиспользовать перехваченные данные, а токен остается на сервере: проверять подпись в JavaScript на странице нельзя. Проверка подтверждает лишь, что данные подписал Telegram для вашего бота; сессию, CSRF-защиту и HTTPS делают отдельно. Для Mini Apps ключ для initData вычисляется иначе.

Long polling или webhook

Критерий Long polling Webhook
Нужен публичный HTTPS-адрес нет да
Работает с ноутбука за NAT да нет без туннеля
Сколько экземпляров бота на токен один несколько за балансировщиком
Когда брать учеба, внутренние боты, небольшая нагрузка продакшен с нагрузкой, serverless

Если не получилось

  • Exception in thread "main" 401: ...TelegramApiErrorResponseException: Unauthorized при старте. Токен неверный, отозван или скопирован с пробелом. Проверьте его запросом curl https://api.telegram.org/bot<токен>/getMe: на неверный токен Bot API отвечает {"ok":false,"error_code":401,"description":"Unauthorized"}.
  • 409 Conflict. Про другой getUpdates — запущено два экземпляра бота с одним токеном (в IDE и на сервере), оставьте один. Про активный webhook — registerBot снимает его сам, в других клиентах вызовите deleteWebhook.
  • Бот молчит в группе. По умолчанию включен privacy mode, и бот видит в группе не все сообщения. Команды с @имя_бота он получает; для остального отключите режим в BotFather (/setprivacy) и заново добавьте бота в группу.
  • Не собирается: нет класса TelegramLongPollingBot. Код из старого учебника под TelegramBots 6.x и раньше. Используйте API из примера выше.
  • Бот отвечает с задержкой. В consume() идет долгая операция — вынесите ее в ExecutorService.

Перед запуском в работу

Учебный бот — основа, а не готовый сервис. Для реальной работы добавьте: токен в секрет-хранилище сервера, логи без токена и текстов личных сообщений, ограничение частоты запросов от одного пользователя, повтор отправки при сбоях сети и автоперезапуск (systemd или контейнер).

Выводы

  • Telegram-бот на Java — программа, которая получает события через getUpdates или webhook и отвечает вызовами Bot API; актуальная библиотека TelegramBots 10.x использует TelegramBotsLongPollingApplication и OkHttpTelegramClient.
  • Токен от BotFather — это авторизация самого бота; его держат вне кода и отзывают через /revoke при утечке.
  • Ограничивать доступ к боту нужно по user id из getFrom(), а не по username и не по chat id.
  • Вход на сайт через Telegram проверяется на сервере: HMAC-SHA-256 с ключом SHA-256(токен), сравнение за постоянное время и проверка auth_date.
  • Команды надежнее разбирать отдельным методом без сети: так учитываются @имя_бота, регистр и пробелы, а логику легко тестировать.

Где применяется / связь с практикой

Освойте тему на практике

Боты на Java пишут для внутренних уведомлений (сборки, мониторинг, заявки) и поддержки клиентов поверх существующего бэкенда. Сам бот — повод потренировать базу языка: интерфейсы, коллекции, исключения, HTTP, сборку через Maven. Разобрать эту базу по порядку, с домашними заданиями и ревью кода, можно на курсе «Java разработчик. Базовый уровень». Посмотреть, как проходят занятия, можно бесплатно на открытых уроках Otus.

FAQ

Можно ли написать бота на Java без библиотеки?
Да, Bot API — это обычные HTTPS-запросы, их можно отправлять через java.net.http.HttpClient. Но тогда разбор JSON, повторы при ошибках и цикл getUpdates с offset придется писать самому.

Подойдет ли Spring Boot?
Да, у TelegramBots есть отдельные модули для интеграции со Spring Boot. Для первого бота они не нужны: достаточно main из примера.

Где взять свой user id?
Проще всего запустить бота из статьи с пустым ALLOWED_USER_IDS: он ответит «Доступ закрыт» и покажет ваш id.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)