Создаём бота в Telegram для управления платными подписками на канал
Отредактировано
Подробная инструкция о том, как создать бота для предоставления демо-доступа и контроля подписок на приватный канал в Telegram.
27К открытий29К показов
Александр Волков
Руководитель отдела разработки программного обеспечения компании «Синимекс»
Telegram набирает популярность не только у пользователей, но и у разработчиков. Многие создают там свои каналы и ботов. В этой статье мы создадим приватный канал с фасадом для него — ботом.
Содержание:
- Возможности бота
- Создание бота
- Создание базы данных
- Создание бэкенда
- Подключение к API Telegram
- Создание фронтенда
- Вывод
Возможности бота
Через бота можно будет:
- получить полную информацию о канале;
- получить демо-доступ на канал. Пользователь в автоматическом режиме получит ссылку на вступление в приватный канал. Через n дней демо-доступа пользователь будет удалён из канала;
- выдать полный доступ для пользователей. Пользователь сможет отправить данные для получения полного доступа. Данные могут быть проверены как в автоматическом режиме, так и в ручном — самим администратором. По итогам пользователю выдаётся полный доступ.
Дополнительно к этому мы создадим интерфейс на React для управления подписками и сбором аналитики по каналу. Особенность этого решения — простота. Время на его создание — около 4 часов. Все компоненты решения для удобства будут развёрнуты в docker-контейнерах. Схема нашего решения будет выглядеть так:
Создание бота
Начнём с самого простого шага — создания бота в Telegram. Для этого достаточно написать команду /newbot боту по созданию других ботов BotFather и следовать инструкциям:
После успешного создания бота вы получите сообщение:
Также появится токен, который мы будем использовать для подключения к боту из своего приложения.
Бот для приватного канала с информацией о самых вкусных плюшечках и пирожочках создан. Далее создадим сам канал и добавим туда нашего бота в качестве администратора.
Создание базы данных
Для простоты решения поднимем 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 и сгенерируем каркас бэкенд-приложения. Достаточно выбрать следующие библиотеки:
Развернём сгенерированный проект в среде разработки и добавим туда зависимость для работы с 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;
}
}
Готово! Теперь бот умеет обрабатывать следующие команды.
О чём канал?
После получения демо-доступа информация об этом сохраняется в БД. При повторном запросе на демо-доступ пользователю отобразится ошибка:
Если пользователи могут получать временный доступ, то появляется задача проверки истечения этого доступа. К сожалению, не получится создать планировщик, который будет раз в день отписывать пользователей с истекшим доступом. Поэтому надо реализовать поддержку ещё одной команды для чистки подписчиков.
Чтобы этой командой мог пользоваться только администратор, его 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. Исходный код двух приложений можно посмотреть здесь и здесь.
27К открытий29К показов
Также рекомендуем
Новая версия ChatGPT-o1 от OpenAI показала впечатляющий результат в в авторитетном тесте IQ Mensa, достигнув оценки в 120 баллов
Пользователи Telegram из России столкнулись с настойчивыми попытками мошенников украсть их аккаунты. Делается это через схему с участием в голосованиях.
Екатерина Мелентьева рассказала, какие личностные качества в IT важны так же, как и профессиональные, и как их освоить.
Основатель Telegram Павел Дуров был задержан 24 августа 2024 года во Франции по обвинениям в пособничестве терроризму, наркоторговле и отмыванию денег. Арест может привести к экстрадиции Дурова в США, и грозит ему до 20 лет тюрьмы.