Обложка: Создаём бота в Telegram для управления платными подписками на канал

Создаём бота в Telegram для управления платными подписками на канал

2
12
Александр Волков
Александр Волков

Руководитель отдела разработки программного обеспечения компании «Синимекс»

Telegram набирает популярность не только у пользователей, но и у разработчиков. Многие создают там свои каналы и ботов. В этой статье мы создадим приватный канал с фасадом для него — ботом.

Содержание:

  1. Возможности бота
  2. Создание бота
  3. Создание базы данных
  4. Создание бэкенда
  5. Подключение к API Telegram
  6. Создание фронтенда
  7. Вывод

Возможности бота

Через бота можно будет:

  • получить полную информацию о канале;
  • получить демо-доступ на канал. Пользователь в автоматическом режиме получит ссылку на вступление в приватный канал. Через n дней демо-доступа пользователь будет удалён из канала;
  • выдать полный доступ для пользователей. Пользователь сможет отправить данные для получения полного доступа. Данные могут быть проверены как в автоматическом режиме, так и в ручном — самим администратором. По итогам пользователю выдаётся полный доступ.

Дополнительно к этому мы создадим интерфейс на React для управления подписками и сбором аналитики по каналу. Особенность этого решения — простота. Время на его создание — около 4 часов. Все компоненты решения для удобства будут развёрнуты в docker-контейнерах. Схема нашего решения будет выглядеть так:

Схема работы бота

Создание бота

Начнём с самого простого шага — создания бота в Telegram. Для этого достаточно написать команду /newbot боту по созданию других ботов BotFather и следовать инструкциям:

Создать бота в Telegram

 

После успешного создания бота вы получите сообщение:

Успешное создание бота в Telegram

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

Бот для приватного канала с информацией о самых вкусных плюшечках и пирожочках создан. Далее создадим сам канал и добавим туда нашего бота в качестве администратора.

Бот среди администраторов канала

Создание базы данных

Для простоты решения поднимем PostgreSQLв docker-контейнере:

docker network create buns-net
docker run -d --network="buns-net" --name bot-postgres -e POSTGRES_PASSWORD=Pass2020! -p 5432:5432 postgres

Контейнер запущен, БД готова к работе.

Для минимальной аналитики и контроля подписок нам хватит одной таблицы. Заполним её модель и далее в миграции lequibase создадим:

Таблица для контроля подписок

Создание бэкенда

Перейдём на Spring Initializr и сгенерируем каркас бэкенд-приложения. Достаточно выбрать следующие библиотеки:

Создание приложения на Spring

Развернём сгенерированный проект в среде разработки и добавим туда зависимость для работы с Telegram:

compile "org.telegram:telegrambots-spring-boot-starter:5.0.1"

Также добавим библиотеку для мапинга, которая нам пригодится при мапинге сущностей БД в REST-модели:

compile("net.rakugakibox.spring.boot:orika-spring-boot-starter:1.9.0")

Создадим миграцию для таблицы, добавим описание подключения к БД и запустим приложение:

Пример структуры проекта

1-add-tables.yaml
databaseChangeLog:
  - changeSet:
      id: 1-add-tables
      author: avolkov
      changes:
        - createTable:
            tableName: subscriber
            columns:
              - column:
                  name: id
                  type: bigint
                  autoIncrement: true
                  constraints:
                    primaryKey: true
                    nullable: false
              - column:
                  name: name
                  type: varchar(255)
                  constraints:
                    nullable: true
              - column:
                  name: login
                  type: varchar(255)
                  constraints:
                    nullable: true
              - column:
                  name: telegram_id
                  type: varchar(100)
                  constraints:
                    nullable: false
              - column:
                  name: start_subscribe
                  type: timestamp
                  constraints:
                    nullable: false
              - column:
                  name: end_subscribe
                  type: timestamp
                  constraints:
                    nullable: false
              - column:
                  name: type_subscribe
                  type: varchar(50)
                  constraints:
                    nullable: false
              - column:
                  name: enable
                  type: boolean
                  constraints:
                    nullable: false

db.changelog-master.yaml
databaseChangeLog:
  - include:
      file: db/changelog/1-add-tables.yaml
application.yaml
server:
  servlet:
    context-path: /api

spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://bot-postgres:5432/postgres -- при локальной разработке поменять на localhost
    username: postgres
    password: Pass2020!
  jpa:
    properties:
      hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
      hibernate.globally_quoted_identifiers: true

После успешного запуска приложения накатится миграция и создастся таблица:

/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.5.1)
 
2021-06-14 21:54:41.672 INFO 4692 --- [ main] com.example.buns.BunsApplication : Starting BunsApplication using Java 1.8.0_261 on NB-JJBQY33 with PID 4692 (C:\GIT\buns\build\classes\java\main started by avolkov in C:\GIT\buns)
2021-06-14 21:54:41.675 INFO 4692 --- [ main] com.example.buns.BunsApplication : No active profile set, falling back to default profiles: default
2021-06-14 21:54:42.337 INFO 4692 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2021-06-14 21:54:42.352 INFO 4692 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 6 ms. Found 0 JPA repository interfaces.
2021-06-14 21:54:42.861 INFO 4692 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2021-06-14 21:54:42.869 INFO 4692 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2021-06-14 21:54:42.869 INFO 4692 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.46]
2021-06-14 21:54:42.994 INFO 4692 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/api] : Initializing Spring embedded WebApplicationContext
2021-06-14 21:54:42.995 INFO 4692 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1278 ms
2021-06-14 21:54:43.104 INFO 4692 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2021-06-14 21:54:43.191 INFO 4692 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2021-06-14 21:54:43.596 INFO 4692 --- [ main] liquibase.lockservice : Successfully acquired change log lock
2021-06-14 21:54:43.830 INFO 4692 --- [ main] liquibase.changelog : Creating database history table with name: public.databasechangelog
2021-06-14 21:54:43.847 INFO 4692 --- [ main] liquibase.changelog : Reading from public.databasechangelog
2021-06-14 21:54:43.903 INFO 4692 --- [ main] liquibase.lockservice : Successfully released change log lock
2021-06-14 21:54:43.917 INFO 4692 --- [ main] liquibase.lockservice : Successfully acquired change log lock
Skipping auto-registration
2021-06-14 21:54:43.919 WARN 4692 --- [ main] liquibase.hub : Skipping auto-registration
2021-06-14 21:54:43.937 INFO 4692 --- [ main] liquibase.changelog : Table subscriber created
2021-06-14 21:54:43.941 INFO 4692 --- [ main] liquibase.changelog : ChangeSet db/changelog/1-add-tables.yaml::1-add-tables::avolkov ran successfully in 17ms
2021-06-14 21:54:43.956 INFO 4692 --- [ main] liquibase.lockservice : Successfully released change log lock
2021-06-14 21:54:44.066 INFO 4692 --- [ main] o.hibernate.jpa.internal.util.LogHelper : HHH000204: Processing PersistenceUnitInfo [name: default]
2021-06-14 21:54:44.125 INFO 4692 --- [ main] org.hibernate.Version : HHH000412: Hibernate ORM core version 5.4.32.Final
2021-06-14 21:54:44.271 INFO 4692 --- [ main] o.hibernate.annotations.common.Version : HCANN000001: Hibernate Commons Annotations {5.1.2.Final}
2021-06-14 21:54:44.446 INFO 4692 --- [ main] org.hibernate.dialect.Dialect : HHH000400: Using dialect: org.hibernate.dialect.PostgreSQLDialect
2021-06-14 21:54:44.709 INFO 4692 --- [ main] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
2021-06-14 21:54:44.739 INFO 4692 --- [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2021-06-14 21:54:44.827 WARN 4692 --- [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2021-06-14 21:54:45.185 INFO 4692 --- [ main] .s.s.UserDetailsServiceAutoConfiguration : 
 
Using generated security password: bc6e3fd1-b127-48c0-beb1-4b39a9da39e4
 
2021-06-14 21:54:45.291 INFO 4692 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@514377fc, org.springframework.security.web.context.SecurityContextPersistenceFilter@6af78a48, org.springframework.security.web.header.HeaderWriterFilter@1ec88aa1, org.springframework.security.web.csrf.CsrfFilter@3111631d, org.springframework.security.web.authentication.logout.LogoutFilter@8f57e4c, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@46612bfc, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@66451058, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@2e4eda17, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@4662752a, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@4c24f3a2, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@68aec50, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@7b5021d1, org.springframework.security.web.session.SessionManagementFilter@28f154cc, org.springframework.security.web.access.ExceptionTranslationFilter@2aa5bd48, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@54326e9]
2021-06-14 21:54:45.457 INFO 4692 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '/api'
2021-06-14 21:54:45.480 INFO 4692 --- [ main] com.example.buns.BunsApplication : Started BunsApplication in 4.248 seconds (JVM running for 5.262)

Таблица создана, приложение успешно запустилось. Теперь можно приступить к описанию Data Access Layer. Для этого опишем сущность, репозиторий и сервис:

Структура бота

Подключение к API Telegram

Чтобы можно было получать данные из бота в наше приложение, достаточно унаследовать от абстрактного класса org.telegram.telegrambots.bots.TelegramLongPollingBot и реализовать три метода:

public String getBotUsername(); // логин бота, который устанавливался при создании бота
public String getBotToken(); // токен, полученный при создании от BotFather
public void onUpdateReceived(Update update); // метод срабатывает каждый раз, когда боту отправляется сообщение

Добавим в application.yaml данные бота, которые получили от BotFather:

telegram:
  name: bot_login
  token: 164024384:AAFHwer2342pVF3zm_wZ45454554JVr_I
  chanel-id: -1001415979632 -- id приватного канала, куда будет даваться доступ

Реализуем поддержку 5 команд:·

  • /info — запрос информации о боте и канале;
  • /start — такая же, как и info. Нужна для первого сообщения с ботом;
  • /demo — получение демо-доступа на канал;
  • /access — запрос на получение информации о полном доступе;
  • /success — запрос на получение полного доступа.

Чтобы всеми этими командами было удобно пользоваться, реализуем их в виде кнопок, используя org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup.

TelegramBotHandler.java
package com.example.buns.service;

import com.example.buns.dal.entity.TypeSubscribe;
import com.example.buns.rest.model.Subscriber;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.api.methods.ParseMode;
import org.telegram.telegrambots.meta.api.methods.groupadministration.ExportChatInviteLink;
import org.telegram.telegrambots.meta.api.methods.groupadministration.KickChatMember;
import org.telegram.telegrambots.meta.api.methods.groupadministration.UnbanChatMember;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * TelegramBotHandler.
 *
 * @author avolkov
 */

@Component
@Data
@Slf4j
public class TelegramBotHandler extends TelegramLongPollingBot {

    private final String INFO_LABEL = "О чем канал?";
    private final String ACCESS_LABEL = "Как получить доступ?";
    private final String SUCCESS_LABEL = "Дайте полный доступ!";
    private final String DEMO_LABEL = "Хочу демо-доступ на 3 дня";

    private final SubscribersService subscribersService;

    private enum COMMANDS {
        INFO("/info"),
        START("/start"),
        DEMO("/demo"),
        ACCESS("/access"),
        SUCCESS("/success");

        private String command;

        COMMANDS(String command) {
            this.command = command;
        }

        public String getCommand() {
            return command;
        }
    }

    @Value("${telegram.name}")
    private String name;

    @Value("${telegram.token}")
    private String token;

    @Value("${telegram.chanel-id}")
    private String privateChannelId;

    @Override
    public String getBotUsername() {
        return name;
    }

    @Override
    public String getBotToken() {
        return token;
    }

    @Override
    public void onUpdateReceived(Update update) {
        if (update.hasMessage() && update.getMessage().hasText()) {
            String text = update.getMessage().getText();
            long chat_id = update.getMessage().getChatId();

            try {
                SendMessage message = getCommandResponse(text, update.getMessage().getFrom(), String.valueOf(chat_id));
                message.enableHtml(true);
                message.setParseMode(ParseMode.HTML);
                message.setChatId(String.valueOf(chat_id));
                execute(message);
            } catch (TelegramApiException e) {
                log.error("", e);
                SendMessage message = handleNotFoundCommand();
                message.setChatId(String.valueOf(chat_id));
            }
        } else if (update.hasCallbackQuery()) {
            try {
                SendMessage message = getCommandResponse(update.getCallbackQuery().getData(), update.getCallbackQuery().getFrom(), String.valueOf(update.getCallbackQuery().getMessage().getChatId()));
                message.enableHtml(true);
                message.setParseMode(ParseMode.HTML);
                message.setChatId(String.valueOf(update.getCallbackQuery().getMessage().getChatId()));
                execute(message);
            } catch (TelegramApiException e) {
                log.error("", e);
            }
        }
    }

    private SendMessage getCommandResponse(String text, User user, String chatId) throws TelegramApiException {
        if (text.equals(COMMANDS.INFO.getCommand())) {
            return handleInfoCommand();
        }

        if (text.equals(COMMANDS.ACCESS.getCommand())) {
            return handleAccessCommand();
        }

        if (text.equals(COMMANDS.SUCCESS.getCommand())) {
            return handleSuccessCommand();
        }

        if (text.equals(COMMANDS.START.getCommand())) {
            return handleStartCommand();
        }

        if (text.equals(COMMANDS.DEMO.getCommand())) {
            return handleDemoCommand(user.getUserName(), String.valueOf(user.getId()), user.getFirstName(), chatId);
        }

        return handleNotFoundCommand();
    }

    private SendMessage handleNotFoundCommand() {
        SendMessage message = new SendMessage();
        message.setText("Вы что-то сделали не так. Выберите команду:");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private String getChatInviteLink() throws TelegramApiException {
        ExportChatInviteLink exportChatInviteLink = new ExportChatInviteLink();
        exportChatInviteLink.setChatId(privateChannelId);
        return execute(exportChatInviteLink);
    }

    private SendMessage handleDemoCommand(String username, String id, String name, String chatId) throws TelegramApiException {
        SendMessage message = new SendMessage();

        if (subscribersService.isDemoAccess(chatId)) {
            message.setText("Ссылка для доступа к закрытому каналу: " + getChatInviteLink() + " \nЧерез 3 дня вы будете исключены из канала");

            addInfoSubscriberToDb(username, chatId, name, TypeSubscribe.DEMO);
        } else {
            message.setText("Вы уже получали демо-доступ");
        }

        message.setReplyMarkup(getKeyboard());

        return message;
    }

    private Subscriber addInfoSubscriberToDb(String username, String chatId, String name,
                                             TypeSubscribe typeSubscribe) {
        Subscriber subscriber = new Subscriber();
        subscriber.setTypeSubscribe(typeSubscribe);
        subscriber.setTelegramId(chatId);
        subscriber.setName(name);
        subscriber.setLogin(username);
        subscriber.setStartDate(LocalDateTime.now());

        return subscribersService.add(subscriber);
    }

    private SendMessage handleStartCommand() {
        SendMessage message = new SendMessage();
        message.setText("Доступные команды:");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private SendMessage handleInfoCommand() {
        SendMessage message = new SendMessage();
        message.setText("Это канал о самых вкусных пирожочках");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private SendMessage handleAccessCommand() {
        SendMessage message = new SendMessage();
        message.setText("Чтобы получить полный доступ, вам надо сказать волшебное слово");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private SendMessage handleSuccessCommand() {
        SendMessage message = new SendMessage();
        message.setText("После проверки вам выдадут полный доступ");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private InlineKeyboardMarkup getKeyboard() {
        InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup();

        InlineKeyboardButton inlineKeyboardButton = new InlineKeyboardButton();
        inlineKeyboardButton.setText(INFO_LABEL);
        inlineKeyboardButton.setCallbackData(COMMANDS.INFO.getCommand());

        InlineKeyboardButton inlineKeyboardButtonAccess = new InlineKeyboardButton();
        inlineKeyboardButtonAccess.setText(ACCESS_LABEL);
        inlineKeyboardButtonAccess.setCallbackData(COMMANDS.ACCESS.getCommand());

        InlineKeyboardButton inlineKeyboardButtonDemo = new InlineKeyboardButton();
        inlineKeyboardButtonDemo.setText(DEMO_LABEL);
        inlineKeyboardButtonDemo.setCallbackData(COMMANDS.DEMO.getCommand());

        InlineKeyboardButton inlineKeyboardButtonSuccess = new InlineKeyboardButton();
        inlineKeyboardButtonSuccess.setText(SUCCESS_LABEL);
        inlineKeyboardButtonSuccess.setCallbackData(COMMANDS.SUCCESS.getCommand());

        List<List<InlineKeyboardButton>> keyboardButtons = new ArrayList<>();

        List<InlineKeyboardButton> keyboardButtonsRow1 = new ArrayList<>();
        keyboardButtonsRow1.add(inlineKeyboardButton);
        keyboardButtonsRow1.add(inlineKeyboardButtonAccess);

        List<InlineKeyboardButton> keyboardButtonsRow2 = new ArrayList<>();
        keyboardButtonsRow2.add(inlineKeyboardButtonSuccess);

        List<InlineKeyboardButton> keyboardButtonsRow3 = new ArrayList<>();
        keyboardButtonsRow3.add(inlineKeyboardButtonDemo);

        keyboardButtons.add(keyboardButtonsRow1);
        keyboardButtons.add(keyboardButtonsRow3);
        keyboardButtons.add(keyboardButtonsRow2);

        inlineKeyboardMarkup.setKeyboard(keyboardButtons);

        return inlineKeyboardMarkup;
    }
}

Готово! Теперь бот умеет обрабатывать следующие команды.

О чём канал?

Результат ответа на вопрос, о чём каналКак получить доступ?

Результат ответа на вопрос, как получить доступХочу демо-доступ на 3 дня.

Отправка запроса на получение демо-доступа

После получения демо-доступа информация об этом сохраняется в БД. При повторном запросе на демо-доступ пользователю отобразится ошибка:

Ошибка при повторном запросе демо-доступа

Если пользователи могут получать временный доступ, то появляется задача проверки истечения этого доступа. К сожалению, не получится создать планировщик, который будет раз в день отписывать пользователей с истекшим доступом. Поэтому надо реализовать поддержку ещё одной команды для чистки подписчиков.

Чтобы этой командой мог пользоваться только администратор, его Chat ID надо добавить в application.yaml. Далее этот идентификатор будет использоваться при проверке, от кого пришла команда и имеет ли этот пользователь права на выполнение этой команды.

telegram:
  name: bot_login
  token: 164024384:AAFHwer2342pVF3zm_wZ45454554JVr_I
  chanel-id: -1001415979632 -- id приватного канала, куда будет выдаваться доступ
  support:
    chat-id: 14334538544

Дополнительно к этому требуется реализовать команду выдачи полного доступа. Класс поменяется следующим образом:

package com.example.buns.service;

import com.example.buns.dal.entity.TypeSubscribe;
import com.example.buns.rest.model.Subscriber;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.api.methods.ParseMode;
import org.telegram.telegrambots.meta.api.methods.groupadministration.ExportChatInviteLink;
import org.telegram.telegrambots.meta.api.methods.groupadministration.KickChatMember;
import org.telegram.telegrambots.meta.api.methods.groupadministration.UnbanChatMember;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * TelegramBotHandler.
 *
 * @author avolkov
 */

@Component
@Data
@Slf4j
public class TelegramBotHandler extends TelegramLongPollingBot {

    private final String INFO_LABEL = "О чем канал?";
    private final String ACCESS_LABEL = "Как получить доступ?";
    private final String SUCCESS_LABEL = "Дайте полный доступ!";
    private final String DEMO_LABEL = "Хочу демо-доступ на 3 дня";

    private final SubscribersService subscribersService;

    private enum COMMANDS {
        INFO("/info"),
        START("/start"),
        DEMO("/demo"),
        ACCESS("/access"),
        SUCCESS("/success");

        private String command;

        COMMANDS(String command) {
            this.command = command;
        }

        public String getCommand() {
            return command;
        }
    }

    @Value("${telegram.support.chat-id}")
    private String supportChatId;

    @Value("${telegram.name}")
    private String name;

    @Value("${telegram.token}")
    private String token;

    @Value("${telegram.chanel-id}")
    private String privateChannelId;

    @Override
    public String getBotUsername() {
        return name;
    }

    @Override
    public String getBotToken() {
        return token;
    }

    public void onUpdateReceived(Update update) {
        if (update.hasMessage() && update.getMessage().hasText()) {
            String text = update.getMessage().getText();
            long chat_id = update.getMessage().getChatId();

            try {
                Pattern patternTimeCommand = Pattern.compile("^message\\splease$");
                Pattern patternClear = Pattern.compile("^clear-expired$");
                Pattern patternGiveRights = Pattern.compile("^give-rights\\s(\\d*)$");

                Matcher matcherTimeCommand = patternTimeCommand.matcher(text);
                Matcher matcherClear = patternClear.matcher(text);
                Matcher matcherGiveRights = patternGiveRights.matcher(text);


                if (matcherTimeCommand.find()) {
                    sendInfoToSupport("Пользователь запросил полный доступ:\n" +
                            "\nLogin: @" + update.getMessage().getFrom().getUserName() +
                            "\nName: " + update.getMessage().getFrom().getFirstName() + " " + update.getMessage().getFrom().getLastName() +
                            "\nChat ID: [" + chat_id + "](" + chat_id + ")");

                    SendMessage messageSuccess = new SendMessage();
                    messageSuccess.setText("Ваши данные получены. Идет проверка.");
                    messageSuccess.setChatId(String.valueOf(chat_id));
                    execute(messageSuccess);
                } else if (matcherClear.find() && isAdmin(String.valueOf(chat_id))) {
                    clearExpired();
                } else if (matcherGiveRights.find() && isAdmin(String.valueOf(chat_id))) {
                    giveRights(matcherGiveRights.group(1));
                } else {
                    SendMessage message = getCommandResponse(text, update.getMessage().getFrom(), String.valueOf(chat_id));
                    message.enableHtml(true);
                    message.setParseMode(ParseMode.HTML);
                    message.setChatId(String.valueOf(chat_id));
                    execute(message);
                }
            } catch (TelegramApiException e) {
                e.printStackTrace();
                SendMessage message = handleNotFoundCommand();
                message.setChatId(String.valueOf(chat_id));
                try {
                    sendInfoToSupport("Error " + e.getMessage());

                    execute(message);
                } catch (TelegramApiException ex) {
                    ex.printStackTrace();
                }
            }
        } else if (update.hasCallbackQuery()) {
            try {
                SendMessage message = getCommandResponse(update.getCallbackQuery().getData(), update.getCallbackQuery().getFrom(), String.valueOf(update.getCallbackQuery().getMessage().getChatId()));
                message.enableHtml(true);
                message.setParseMode(ParseMode.HTML);
                message.setChatId(String.valueOf(update.getCallbackQuery().getMessage().getChatId()));
                execute(message);
            } catch (TelegramApiException e) {
                e.printStackTrace();
                try {
                    sendInfoToSupport("Error " + e.getMessage());
                } catch (TelegramApiException ex) {
                    ex.printStackTrace();
                }
            }
        }
    }

    private boolean isAdmin(String chatId) {
        return chatId.equals(supportChatId);
    }

    private void clearExpired() throws TelegramApiException {
        List<Subscriber> subscribers = subscribersService.getExpired();
        List<Subscriber> successDeleted = new ArrayList<>();

        for (Subscriber subscriber : subscribers) {
            try {
                KickChatMember kickChatMember = new KickChatMember();
                kickChatMember.setChatId(privateChannelId);
                kickChatMember.setUserId(Integer.valueOf(subscriber.getTelegramId()));
                execute(kickChatMember);

                subscribersService.disable(subscriber.getId());

                SendMessage message = new SendMessage();
                message.setText("Ваш доступ к каналу окончен");
                message.setChatId(subscriber.getTelegramId());
                execute(message);

                successDeleted.add(subscriber);

                Thread.sleep(100);
            } catch (Exception ex) {
                ex.printStackTrace();
                sendInfoToSupport("Ошибка при удалении \nChat ID = " + subscriber.getTelegramId() + "\nID = " + subscriber.getId() + "\n" + ex.getMessage());
            }
        }
    }

    private void giveRights(String chatId) throws TelegramApiException {

        try {
            UnbanChatMember unbanChatMember = new UnbanChatMember();
            unbanChatMember.setChatId(privateChannelId);
            unbanChatMember.setOnlyIfBanned(false);
            unbanChatMember.setUserId(Integer.valueOf(chatId));

            execute(unbanChatMember);
        } catch (TelegramApiException e) {
            try {
                sendInfoToSupport("Ощибка при удалении пользователя из бана: " + e.getMessage());
            } catch (TelegramApiException ex) {
                ex.printStackTrace();
            }
        }

        addInfoSubscriberToDb(null, chatId, null, TypeSubscribe.FULL);

        SendMessage message = new SendMessage();
        message.setChatId(chatId);
        message.setText("Вам выдан полный доступ: " + getChatInviteLink());
        execute(message);

        sendInfoToSupport("Выдан полный доступ для пользователя " + chatId);
    }

    private void sendInfoToSupport(String message) throws TelegramApiException {
        SendMessage messageSupport = new SendMessage();
        messageSupport.setText(message);
        messageSupport.setChatId(supportChatId);

        execute(messageSupport);
    }

    private SendMessage getCommandResponse(String text, User user, String chatId) throws TelegramApiException {
        if (text.equals(COMMANDS.INFO.getCommand())) {
            return handleInfoCommand();
        }

        if (text.equals(COMMANDS.ACCESS.getCommand())) {
            return handleAccessCommand();
        }

        if (text.equals(COMMANDS.SUCCESS.getCommand())) {
            return handleSuccessCommand();
        }

        if (text.equals(COMMANDS.START.getCommand())) {
            return handleStartCommand();
        }

        if (text.equals(COMMANDS.DEMO.getCommand())) {
            return handleDemoCommand(user.getUserName(), String.valueOf(user.getId()), user.getFirstName(), chatId);
        }

        return handleNotFoundCommand();
    }

    private SendMessage handleNotFoundCommand() {
        SendMessage message = new SendMessage();
        message.setText("Вы что-то сделали не так. Выберите команду:");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private String getChatInviteLink() throws TelegramApiException {
        ExportChatInviteLink exportChatInviteLink = new ExportChatInviteLink();
        exportChatInviteLink.setChatId(privateChannelId);
        return execute(exportChatInviteLink);
    }

    private SendMessage handleDemoCommand(String username, String id, String name, String chatId) throws TelegramApiException {
        SendMessage message = new SendMessage();

        if (subscribersService.isDemoAccess(chatId)) {
            message.setText("Ссылка для доступа к закрытому каналу: " + getChatInviteLink() + " \nЧерез 3 дня вы будете исключены из канала");

            addInfoSubscriberToDb(username, chatId, name, TypeSubscribe.DEMO);
        } else {
            message.setText("Вы уже получали демо-доступ");
        }

        message.setReplyMarkup(getKeyboard());

        return message;
    }

    private Subscriber addInfoSubscriberToDb(String username, String chatId, String name,
                                             TypeSubscribe typeSubscribe) {
        Subscriber subscriber = new Subscriber();
        subscriber.setTypeSubscribe(typeSubscribe);
        subscriber.setTelegramId(chatId);
        subscriber.setName(name);
        subscriber.setLogin(username);
        subscriber.setStartDate(LocalDateTime.now());

        return subscribersService.add(subscriber);
    }

    private SendMessage handleStartCommand() {
        SendMessage message = new SendMessage();
        message.setText("Доступные команды:");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private SendMessage handleInfoCommand() {
        SendMessage message = new SendMessage();
        message.setText("Это канал о самых вкусных пирожочках");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private SendMessage handleAccessCommand() {
        SendMessage message = new SendMessage();
        message.setText("Чтобы получить полный доступ, вам надо сказать волшебное слово. Отправьте следующий текст: message please");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private SendMessage handleSuccessCommand() {
        SendMessage message = new SendMessage();
        message.setText("После проверки вам выдадут полный доступ");
        message.setReplyMarkup(getKeyboard());
        return message;
    }

    private InlineKeyboardMarkup getKeyboard() {
        InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup();

        InlineKeyboardButton inlineKeyboardButton = new InlineKeyboardButton();
        inlineKeyboardButton.setText(INFO_LABEL);
        inlineKeyboardButton.setCallbackData(COMMANDS.INFO.getCommand());

        InlineKeyboardButton inlineKeyboardButtonAccess = new InlineKeyboardButton();
        inlineKeyboardButtonAccess.setText(ACCESS_LABEL);
        inlineKeyboardButtonAccess.setCallbackData(COMMANDS.ACCESS.getCommand());

        InlineKeyboardButton inlineKeyboardButtonDemo = new InlineKeyboardButton();
        inlineKeyboardButtonDemo.setText(DEMO_LABEL);
        inlineKeyboardButtonDemo.setCallbackData(COMMANDS.DEMO.getCommand());

        InlineKeyboardButton inlineKeyboardButtonSuccess = new InlineKeyboardButton();
        inlineKeyboardButtonSuccess.setText(SUCCESS_LABEL);
        inlineKeyboardButtonSuccess.setCallbackData(COMMANDS.SUCCESS.getCommand());

        List<List<InlineKeyboardButton>> keyboardButtons = new ArrayList<>();

        List<InlineKeyboardButton> keyboardButtonsRow1 = new ArrayList<>();
        keyboardButtonsRow1.add(inlineKeyboardButton);
        keyboardButtonsRow1.add(inlineKeyboardButtonAccess);

        List<InlineKeyboardButton> keyboardButtonsRow2 = new ArrayList<>();
        keyboardButtonsRow2.add(inlineKeyboardButtonSuccess);

        List<InlineKeyboardButton> keyboardButtonsRow3 = new ArrayList<>();
        keyboardButtonsRow3.add(inlineKeyboardButtonDemo);

        keyboardButtons.add(keyboardButtonsRow1);
        keyboardButtons.add(keyboardButtonsRow3);
        keyboardButtons.add(keyboardButtonsRow2);

        inlineKeyboardMarkup.setKeyboard(keyboardButtons);

        return inlineKeyboardMarkup;
    }
}

Появилась обработка команд от администратора.

Если пользователь отправит сообщение message please, то администратору от бота придет сообщение:

Пример запроса полного доступа к каналу

После этого администратор может отправить боту команду на выдачу прав пользователю:

Пример выдачи полного доступа пользователю

И пользователю от бота придет сообщение:

Пример сообщения со ссылкой для полного доступа к каналу

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

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

В свою очередь, у администратора канала появились следующие возможности:

  • проверить предоставляемые пользователем данные для полного доступа и выдать полный доступ;
  • очистить канал от пользователей с истекшим доступом.

Для удобства развёртывания упакуем наше приложение в Docker и развернём. Добавим в build.gradle:

bootJar {
    archiveFileName = "buns.jar"
}

Создадим Dockerfile:

FROM openjdk:8-jdk-alpine
COPY build/libs/buns.jar buns.jar
EXPOSE 8081
ENTRYPOINT ["java","-jar","/buns.jar"]

Далее выполним следующие команды для создания образа и запуска его в докере:

gradlew build
docker build -t buns-api .

docker run -d -p 8080:8080 --network="buns-net" --name buns-api buns-api

Создание фронтенда

После того, как мы создали handler для обработки команд бота, хочется визуально контролировать подписки и смотреть графики по приросту подписок в разрезе месяца. Для этого создадим отдельное приложение на React с использованием Ant Design. Выполним команду:

npx create-react-app my-app

После этого у нас будет сгенерировано приложение с минимальной функциональностью. Однако требуется установить ещё несколько библиотек:

yarn add antd axios moment react-query @ant-design/charts

После этого основной компонент надо изменить на:

App.js
import './App.css';
import { QueryClient, QueryClientProvider } from 'react-query'
import SubscriberTable from "./SubscriberTable";
import React from "react";
import 'antd/dist/antd.css';

const queryClient = new QueryClient();

function App() {
  return <QueryClientProvider client={queryClient}>
      <SubscriberTable />
  </QueryClientProvider>;
}

export default App;

И добавить новый компонент:

SubscriberTable.jsx
import { Col, Row, Table } from "antd";
import { Column   } from '@ant-design/charts';
import moment from "moment";
import SubscribersApi from "./api/subscribersApi";
import { useQuery } from 'react-query'

const SubscriberTable = () => {
   const columns = [
      {
         title: 'ID',
         dataIndex: 'id',
      },
      {
         title: 'Имя',
         dataIndex: 'name',
      },
      {
         title: 'Логин',
         dataIndex: 'login',
      },
      {
         title: 'Chat ID',
         dataIndex: 'telegramId',
      },
      {
         title: 'Подписка',
         dataIndex: 'typeSubscribe',
         render: (text, record) => record.typeSubscribe === 'DEMO' ? "Демо" : "Полная"
      },
      {
         title: 'Начало подписки',
         dataIndex: 'startDate',
         render: (text, record) => moment(record.startDate).format('DD-MM-YYYY')
      },
      {
         title: 'Конец подписки',
         dataIndex: 'finishDate',
         render: (text, record) => <span
            style={{color: moment(record.finishDate) < moment() ? 'red' : ''}}>{moment(record.finishDate).format('DD-MM-YYYY')}</span>
      },
   ];

   const data = useQuery([SubscribersApi.name, SubscribersApi.getAll.name], SubscribersApi.getAll);
   const statSubData = useQuery([SubscribersApi.name, SubscribersApi.getStatistic.name], SubscribersApi.getStatistic);

   const config = {
      data: statSubData.data || [],
      xField: 'month',
      yField: 'count'
   };

   return <>
      <Row gutter={24}>
         <Col span={24}>
            <Table dataSource={data.data} columns={columns}/>
         </Col>
      </Row>
      <Row gutter={24}>
         <Col span={24}>
            <Column  {...config} height={200}/>
         </Col>
      </Row>
   </>

};

export default SubscriberTable;

Дополнительно к этому добавить функции для выполнения запросов:

axiosInstance.js
import axios from 'axios';

const host = process.env.REACT_APP_API_ENDPOINT;
export const baseUrl = `http://localhost:8080/api/`;

const instance = axios.create({
  baseURL: `${baseUrl}`,
  withCredentials: true,
  headers: {
    'Cache-Control': 'no-store, no-cache, must-revalidate',
    Pragma: 'no-cache',
  },
});

instance.defaults.headers.common['Accept-Language'] = 'ru-RU, ru';

instance.interceptors.request.use(config => {
  return {
    ...config,
    url: encodeURI(config.url),
  };
});

export default instance;
subscribersApi.js
import * as log from 'loglevel';
import instance from './axiosInstance';

const SUBSCRIBER_PATH = '/subscribers';

const SubscribersApi = {
   name: 'SubscribersApi',
   async getAll() {
      return instance
         .get(`${SUBSCRIBER_PATH}`, {
            auth: {
               username: 'admin',
               password: 'admin'
            }})
         .then(response => {
            log.info(`All Lines`, response);
            return response.data;
         })
         .catch([]);
   },

   async getStatistic() {
      return instance
         .get(`${SUBSCRIBER_PATH}/stat`)
         .then(response => {
            return response.data;
         })
         .catch([]);
   },

};

export default SubscribersApi;

Это личный проект, аутентификация зашита, можно с ней особо не заморачиваться. Также в бэкенд требуется добавить конфигурацию Spring Security:

SecurityConfig.java
package com.example.buns.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

/**
 * SecurityConfig.
 *
 * @author avolkov
 */

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf()
                .disable()
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .httpBasic()
                .and()
                .logout()
                .logoutSuccessHandler((request, response, authentication) -> response.setStatus(401));

        http.cors();

        http.sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
                .maximumSessions(3);
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        List<String> origins = new ArrayList<>();
        origins.add("http://localhost");
        origins.add("http://localhost:3000");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedOrigins(origins);
        corsConfiguration.setAllowedMethods(Arrays.asList("HEAD",
                "GET", "POST", "PUT", "DELETE", "PATCH"));
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.setAllowedHeaders(Collections.singletonList("*"));
        source.registerCorsConfiguration("/**", corsConfiguration);
        return source;
    }


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication().passwordEncoder(NoOpPasswordEncoder.getInstance())
                .withUser("admin").password("admin").roles("ADMIN");
    }


В итоге после выполнения команды npm start
 откроется http://localhost:3000/ с отображением списка подписок и статистикой. Статистику можно выводить в любом разрезе. В текущей реализации показывается, в каком месяце сколько полных подписок было оформлено.

Пример аналитики подписок на канал

А теперь для удобства упакуем и развернём UI в docker-контейнере. Для этого создадим DockerFile:

Dockerfile
FROM nginx:1.19.5

COPY build /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/

RUN chown -R nginx:nginx /var/cache/nginx /etc/nginx/ /var/run/

USER nginx

EXPOSE 80

ENTRYPOINT ["nginx", "-g", "daemon off;"]
nginx.conf
worker_processes  1;

pid        /tmp/nginx.pid;


events {
    worker_connections  1024;
}


http {
    proxy_temp_path /tmp/proxy_temp;
    client_body_temp_path /tmp/client_temp;
    fastcgi_temp_path /tmp/fastcgi_temp;
    uwsgi_temp_path /tmp/uwsgi_temp;
    scgi_temp_path /tmp/scgi_temp;

    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] '
                      '$server_name to: $proxy_host [$upstream_addr] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

И выполним команды для сборки и развертывания:

npm run build
docker build -t buns-ui .
docker run -d -p 80:80 --name buns-ui buns-ui

Вывод

Используя Spring Вoot, React и Docker, можно быстро создать небольшую систему для контроля подписок и подписчиков на канале в Telegram. Исходный код двух приложений можно посмотреть здесь и здесь.

Хинт для программистов: если зарегистрируетесь на соревнования Huawei Cup, то бесплатно получите доступ к онлайн-школе для участников. Можно прокачаться по разным навыкам и выиграть призы в самом соревновании.

Перейти к регистрации

Что думаете?