Всем привет! В данной статье я хотел бы рассмотреть процесс создания Telegram клиента с помощью кроссплатформенной библиотеки TDLib, Java и Spring Boot.

Что будем делать

В репозитории TDLib есть примеры использвания с различными языками. В случае Java работать с библиотекой мы будем через интерфейс JNI. И давайте попробуем оформить это в виде удобного spring-boot-starter, чтобы у нас была возможность создавать клиента просто добавив зависимость и определив несколько свойств в нашем spring-boot проекте. Но прежде чем приступить, рассмотрим каким образом мы можем взаимодейсвтовать с TDLib.

Как происходит взаимодейсвтие с TDLib

  1. Мы можем создавать запросы к TDLib, чтобы получать различные состояния клиента или информацию с серверов Telegram.

  2. Мы можем получать нотификации о новых событиях в клиенте. Например, что мы получили новое сообщение или появился новый чат и т.д.. Полный список обновлений можно посмотреть в документации.

    Для обработки ответов запросов, а также входящих нотификаций об обновлениях используются реализации интерфейса ResultHandler.

/**
 * Interface for handler for results of queries to TDLib and incoming updates from TDLib.
 */
public interface ResultHandler {
    /**
     * Callback called on result of query to TDLib or incoming update from TDLib.
     *
     * @param object Result of query or update of type TdApi.Update about new events.
     */
    void onResult(TdApi.Object object);
}

Далее в примерах мы посмотрим как создать запрос, а также научимся обрабатывать обновления в клиенте, которые присылает Telegram. Ну и раз уж мы создаем spring-boot-starter, то сделаем некоторый адаптер над нативным клиентом для удобства и простоты использования. Хороший пример использования нативного клента можно посмотреть в репозитории TDLib.

Создадим наш Spring Boot Starter

Какую функциональность хотелось бы получить? Хочется, чтобы Telegram клиент создавался просто при добавлении зависимости нашего стартера в проект и определении нескольких свойств в application.properties. Для обращения к TDLib создадим bean класса-адаптера нативного клиента, назовём его TelegramClient. А чтобы получать и обрабатывать нотификации об обновлениях в клиенте, сделаем возможноть регистрировать их как обычные компоненты спринга с указанием ожидаемого типа обновления. Должна быть простая процеура авторизации клиента(код авторизации/смс от Telegram, пароль для двухфакторной верификации), если это необходимо. Это понадобится при первом запуске клиента или если был выполнен LogOut.

В самом начале нам нужно собрать нативную библиотеку tdjni под нашу ОС и архитектуру, сделать это можно по инструкции. В репозитории этого проекта я уже собрал библиотеки для версии TDLib 1.8.13 (macos/windows/centos), взять их можно в каталоге libs.

Создадим проект нашего стартера с помощью Maven:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>3.0.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>dev.voroby</groupId>
    <artifactId>spring-boot-starter-telegram</artifactId>
    <version>1.0.0</version>
    
    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <!--   Tools   -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

В каталоге проекта resources/META-INF создадим файл spring.factories, чтобы указать Spring Boot, какой наш класс можно использовать в качестве автоконфигурации. Давайте укажем его и далее создадим:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=dev.voroby.springframework.telegram.TelegramClientAutoConfiguration

Рассмотрим создание класса TelegramClientAutoConfiguration подробнее. Укажем условия конфигурации и те свойства нативного клиента, которые являются обязательными:

/**
 * Telegram Spring Boot client AutoConfiguration.
 *
 * @author Pavel Vorobyev
 */
@Configuration
@ConditionalOnProperty(
        prefix = "spring.telegram.client",
        name = {
                "database-encryption-key",
                "api-id",
                "api-hash",
                "phone",
                "system-language-code",
                "device-model"
        }
)
@ConfigurationPropertiesScan(basePackages = "dev.voroby.springframework.telegram.properties")
public class TelegramClientAutoConfiguration {

Полную документацию по набору свойств можно посмотреть в репозитории проекта, там можно найти свойства для прокси, мобильной базы данных и логирования.

Загрузим нативную бибилиотеку tdjni, которую мы собирали ранее. При работе на Windows мне приходилось подключать допоплнительные библиотеки OpenSSL и zlib, этот кейс описан в документации к TDLib.

//Loading TDLib library
static {
    try {
        String os = System.getProperty("os.name");
        if (os != null && os.toLowerCase().startsWith("windows")) {
            System.loadLibrary("libcrypto-1_1-x64");
            System.loadLibrary("libssl-1_1-x64");
            System.loadLibrary("zlib1");
        }
        System.loadLibrary("tdjni");
    } catch (UnsatisfiedLinkError e) {
        log.error(e.getMessage(), e);
    }
}

Создадим компонент TelegramClient - это класс-адаптер нативного клиента. Через него мы сможем обращаться с запросами к TDLib, а также у нас появится возможность регистрировать обработчиков нотификаций для клиента в виде обычных @Component:

/**
 * Autoconfigured telegram client.
 *
 * @param properties {@link TelegramProperties}
 * @param notificationHandlers collection of {@link UpdateNotificationListener} beans
 * @param defaultHandler default handler for incoming updates
 * @return {@link TelegramClient}
 */
@Bean
public TelegramClient telegramClient(TelegramProperties properties,
                                     Collection<UpdateNotificationListener<?>> notificationHandlers,
                                     Client.ResultHandler defaultHandler) {
    return new TelegramClient(properties, notificationHandlers, defaultHandler);
}

Тут Collection<UpdateNotificationListener<?>> - это как раз регистрируемые нами обработчики нотификаций от TDLib для TelegramClient. В дальнейшем мы посмотрим пример как их можно использовать, пока проcто посмотрим на интерфейс:

/**
 * Interface for incoming updates from TDLib.
 * @param <T> type of update
 *
 * @author Vorobyev Pavel
 */
public interface UpdateNotificationListener<T extends TdApi.Update> {

    /**
     * Handles incoming update event.
     *
     * @param notification incoming update from TDLib
     */
    void handleNotification(T notification);

    /**
     * @return listener class type
     */
    Class<T> notificationType();

}

Создадим компонент состояния авторизации клиента, который позволит нам проверять статус и вводить авторизационные данные для проверки:

/**
 * Client authorization state.
 *
 * @return {@link ClientAuthorizationState}
 */
@Bean
public ClientAuthorizationState clientAuthorizationState() {
    return new ClientAuthorizationStateImpl();
}
/**
 * Authorization state of the client.
 * Used for client authorization when {@link TdApi.UpdateAuthorizationState} notification
 * received from TDLib.
 * Check functions will not take effect after `UpdateAuthorizationState` have been processed.
 *
 * @author Pavel Vorobyev
 */
public interface ClientAuthorizationState {
    /**
     * Sends an authentication code to the TDLib for check.
     *
     * @param code authentication code received from another logged in client/SMS/email
     */
    void checkAuthenticationCode(String code);

    /**
     * Sends a password to the TDLib for check.
     *
     * @param password two-step verification password
     */
    void checkAuthenticationPassword(String password);

    /**
     * Sends an email to the TDLib for check.
     *
     * @param email address
     */
    void checkEmailAddress(String email);

    /**
     * @return authentication sate awaiting authentication code
     */
    boolean isWaitAuthenticationCode();

    /**
     * @return authentication sate awaiting two-step verification password
     */
    boolean isWaitAuthenticationPassword();

    /**
     * @return authentication sate awaiting email address
     */
    boolean isWaitEmailAddress();

    /**
     * @return authorization status
     */
    boolean haveAuthorization();
}

Чтобы при создании клиента не приходилось каждый раз писать обработчики изменения состояния авторизации, то давайте подготовим такой обработчик и добавим его в автоконфигурацию:

/**
 * Notification listener for authorization sate change.
 *
 * @return {@link UpdateNotificationListener<TdApi.UpdateAuthorizationState>}
 */
@Bean
public UpdateNotificationListener<TdApi.UpdateAuthorizationState> updateAuthorizationNotification(TelegramProperties properties,
                                                                                                  @Lazy TelegramClient telegramClient,
                                                                                                  ClientAuthorizationState clientAuthorizationState) {
    return new UpdateAuthorizationNotification(properties, telegramClient, clientAuthorizationState);
}

Таким образом при добавлении стартера в наше приложение проверять авторизацию можно через ClientAuthorizationState и не нужно писать какую то дополнительную логику по обработке изменениий состояний.

В итоге у нас получился такой вариант автоконфигурации:

TelegramClientAutoConfiguration
/**
 * Telegram Spring Boot client AutoConfiguration.
 *
 * @author Pavel Vorobyev
 */
@Slf4j
@Configuration
@ConditionalOnProperty(
        prefix = "spring.telegram.client",
        name = {
                "database-encryption-key",
                "api-id",
                "api-hash",
                "phone",
                "system-language-code",
                "device-model"
        }
)
@ConfigurationPropertiesScan(basePackages = "dev.voroby.springframework.telegram.properties")
public class TelegramClientAutoConfiguration {

    //Loading TDLib library
    static {
        try {
            String os = System.getProperty("os.name");
            if (os != null && os.toLowerCase().startsWith("windows")) {
                System.loadLibrary("libcrypto-1_1-x64");
                System.loadLibrary("libssl-1_1-x64");
                System.loadLibrary("zlib1");
            }
            System.loadLibrary("tdjni");
        } catch (UnsatisfiedLinkError e) {
            log.error(e.getMessage(), e);
        }
    }

    /**
     * Autoconfigured telegram client.
     *
     * @param properties {@link TelegramProperties}
     * @param notificationHandlers collection of {@link UpdateNotificationListener} beans
     * @param defaultHandler default handler for incoming updates
     * @return {@link TelegramClient}
     */
    @Bean
    public TelegramClient telegramClient(TelegramProperties properties,
                                         Collection<UpdateNotificationListener<?>> notificationHandlers,
                                         Client.ResultHandler defaultHandler) {
        return new TelegramClient(properties, notificationHandlers, defaultHandler);
    }

    /**
     * Client authorization state.
     *
     * @return {@link ClientAuthorizationState}
     */
    @Bean
    public ClientAuthorizationState clientAuthorizationState() {
        return new ClientAuthorizationStateImpl();
    }

    /**
     * Notification listener for authorization sate change.
     *
     * @return {@link UpdateNotificationListener<TdApi.UpdateAuthorizationState>}
     */
    @Bean
    public UpdateNotificationListener<TdApi.UpdateAuthorizationState> updateAuthorizationNotification(TelegramProperties properties,
                                                                                                      @Lazy TelegramClient telegramClient,
                                                                                                      ClientAuthorizationState clientAuthorizationState) {
        return new UpdateAuthorizationNotification(properties, telegramClient, clientAuthorizationState);
    }

    /**
     * @return Default handler for incoming TDLib updates.
     * Could be overwritten by another bean
     */
    @Bean
    public Client.ResultHandler defaultHandler() {
        return (TdApi.Object object) ->
                log.debug("\nSTART DEFAULT HANDLER\n" +
                        object.toString() + "\n" +
                        "END DEFAULT HANDLER");
    }

}

Подробные детали реализаций классов можно посмотреть в репозитории проекта.

Создадим наш Telegram клиент

Создадим spring-boot проект и добавим наш стартер:

<dependency>
    <groupId>dev.voroby</groupId>
    <artifactId>spring-boot-starter-telegram</artifactId>
    <version>1.0.0</version>
</dependency>

Добавим свойства для автоконфигурации в application.properties:

spring.telegram.client.api-id=${TELEGRAM_API_ID}
spring.telegram.client.api-hash=${TELEGRAM_API_HASH}
spring.telegram.client.phone=${TELEGRAM_API_PHONE}
spring.telegram.client.database-encryption-key=${TELEGRAM_API_DATABASE_ENCRYPTION}
spring.telegram.client.device-model=habr_telegram
spring.telegram.client.use-message-database=true
spring.telegram.client.use-file-database=true
spring.telegram.client.use-chat-info-database=true
spring.telegram.client.use-secret-chats=true
spring.telegram.client.log-verbosity-level=1
spring.telegram.client.database-directory=/my/directory/database

Тут следует сказать несколько слов о базе данных, т.к. мы дополнительно указали связанные с ней свойства. TDLib использует зашифрованную sqlite базу, в которой хранится сессия, а также может кэшироваться информация о чатах, сообщениях и т.д.. По-умолчанию база будет создаваться в той же директории, в которой находится jar нашего приложения. Но это можно переопределить, а также указать доп. свойства, что мы и сделали выше. Все обращения к БД происходят через api TDLib.

Внедрим наши компоненты для работы:

@Autowired
private TelegramClient telegramClient;

@Autowired
private ClientAuthorizationState authorizationState;

При запуске приложения нам остается определить jvm-свойство пути до нативных библиотек:

-Djava.library.path=<путь_до_нашей_библиотеки>

Можем воспользоваться уже собранными библиотеками, о которых мы говорили выше. Вот так просто получается создать клиента при наличии стартера!

После первого запуска клиента необходимо авторизовать. Проверить состояние, а также выполнить необходимые действия можно с помощью ClientAuthorizationState, его api мы рассмотрели выше. Также вспомогательную информацию можно найти логах приложения.

INFO 10647 --- [   TDLib thread] .s.t.c.u.UpdateAuthorizationNotification : Please enter authentication code
INFO 10647 --- [   TDLib thread] .s.t.c.u.UpdateAuthorizationNotification : Please enter password

После успешной авторизации создается сессия и сохраняется в БД, при последующих перезапусках клиента использоваться будет эта сессия и повторно проходить авторизацию не нужно, ну до тех пор, пока мы не выполним команду LogOut() явно или не удалим файл БД. После того как authorizationState.haveAuthorization() возвращает true мы можем начать полноценное использование клиента.

Теперь давайте попробуем выполнить простые запросы к TDLib. Запросим информацию о текущем аккаунте и выведем в лог информацию онашем username:

telegramClient.sendWithCallback(new TdApi.GetMe(), obj -> {
            TdApi.User user = (TdApi.User) obj;
            Optional.ofNullable(user.usernames)
                    .ifPresent(usernames -> log.info("Active username: {}", usernames.activeUsernames[0]));
        });

Также можем выполнить запрос в блокирующем стиле, запросим объект чата:

TdApi.Chat chat = telegramClient.sendSync(new TdApi.GetChat(chatId), TdApi.Chat.class);

Полный список классов для запросов можно посмотреть в документации Telegram или посмотреть класс TdApi в коде проекта.

Осталось посмотреть как мы можем получать нотификации обновлений от Telegram в нашем клиенте. Для этого достаточно зарегистрировать компонент, который реализует созданный нами ранее интерфейс <UpdateNotificationListener<T extends TdApi.Update>. Давайте получать информацию о всех новых сообщениях, и, если сообщение текстовое, то будем записывать его в лог:

@Component @Slf4j
public class UpdateNewMessageHandler implements UpdateNotificationListener<TdApi.UpdateNewMessage> {

    @Override
    public void handleNotification(TdApi.UpdateNewMessage notification) {
        TdApi.Message message = notification.message;
        TdApi.MessageContent content = message.content;
        if (content instanceof TdApi.MessageText mt) {
            log.info("Incoming text message:\n[\n\tchatId: {},\n\tmessage: {}\n]", 
                    message.chatId, mt.text.text);
        }
    }

    @Override
    public Class<TdApi.UpdateNewMessage> notificationType() {
        return TdApi.UpdateNewMessage.class;
    }

}

Теперь, умея обращаться к TDLIb и получать от неё нотификации об обновлениях, мы можем реализовать свою версию Telegram! Пример использования стартера и создания простого Telegram клиента можно посмотреть репозитории проекта.

Итоги

Мы познакомились с кроссплатформенной библиотекой клиента Telegram - TDLIb, узнали как можем взаимодействовать с ней. У нас получилось сделать Spring Boot Starter, который позволяет легко создавать клиента с помощью автоконфигурации, также мы предоставили простой api для взаимодействия, упростили процесс авторизации и добавили удобства при работе с обновлениями.

Ещё могу сказать, что процесс изучения TDLib был интересным, многое ещё предстоит освоить! Telegram даёт широкие возможности для разработчиков, будет здорово если вас тоже заинтересует работа с данной библиотекой. Интересно почитать про её использование в других языках/фреймворках. Спасибо за внимание!


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

Комментарии (9)


  1. Inspector-Due
    27.05.2023 21:57
    +1

    Кстати, а существуют ли клиенты на Java, которые напрямую общаются через MTProto? Например, как pyrogram или telethon? Я пытался что-то такое найти, но нашёл лишь какую-то очень старую библиотеку с устаревшим layer (62, емнип).


    1. p_vorobyev Автор
      27.05.2023 21:57
      -3

      Да, в этой статье как раз такой клиент построен :) Т.е. мы собираем нативную библиотеку td_api из официального репозитория telegram, которую после подключаем. В итоге мы можем строить свой полноценный клиент, подключение идет через MTProto, доступно все api. Разве что только на сторонние клиенты telegram может накладывать некоторые ограничения, например, первоначальная регистрация в telegram возможна только в одном из официальных приложений.


      1. p_vorobyev Автор
        27.05.2023 21:57

        Вот тут можно посмотреть инструкции от разработчиков telegram для java.


        1. grossws
          27.05.2023 21:57

          Только это не нативный mtproto на java, про который был вопрос. Но, в целом, сейчас имеет смысл брать tdlib и интегрироваться через jna, jnr-ffi или, как здесь, jni (что, конечно, дополнительная боль).


          1. p_vorobyev Автор
            27.05.2023 21:57

            Да, я неправильно понял. Реализации mtproto на java тут нет, это предоставляет tdlib.


    1. allswell
      27.05.2023 21:57

      Есть библиотека kotlogram для 133 layer, для java публично нет


    1. Faruh3
      27.05.2023 21:57
      +2

      Есть вот такое довольно свежее в разработке нативное для Java решение: https://github.com/Telegram4J/Telegram4J


  1. j-b
    27.05.2023 21:57

    Гм...

    А чем это отличается от использования вполне рабочей библиотеки?

    <dependency>
                <groupId>it.tdlight</groupId>
                <artifactId>tdlight-java</artifactId>
            </dependency>
            <dependency>
                <groupId>it.tdlight</groupId>
                <artifactId>tdlight-natives-linux-amd64</artifactId>
            </dependency>


    1. p_vorobyev Автор
      27.05.2023 21:57

      Здесь я сделал акцент на реализацию клиента в виде spring-boot-starter и удобство использования в spring-boot проектах, постарался добавить некоторую функциональность с учетом возможностей spring. Ну и хотелось самому иметь возможность оперативно вносить изменения при появлени новой версии библиотеки. Как результат - решил поделиться своим опытом)

      tdlight-java - отличный проект!