TL;DR: Если у вас есть Telegram-канал и вы устали руками заполнять отложку, то такой бот здорово облегчит вам жизнь.
Стандартный алгоритм создания отложенного поста выглядит так:
Открыть канал
Создать пост
Выбрать тип публикации "Отложенная"
Указать время поста
Отправить публикацию
При фиксированном интервале между постами алгоритм напрашивается на оптимизацию, в идеале хотелось бы оставить только пункты 1, 2, 5. Причем пункт 2 прокачать до "Создать посты".
Несмотря на обилие готовых решений, большинство их них перегружено функциями (зачастую платными) и работа с ними может, наоборот, увеличить время создания поста. Поэтому было решено реализовать собственного бота, которому можно просто отсылать фотографии (видео, документы, что угодно), а он бы сам уже добавлял их в отложенные публикации, основываясь на времени последнего поста.
Звучит здорово, но этот подход не сработал из-за того, что боту недоступно это самое время последнего поста в канале, а также из-за этого:
Получается без собственной реализации отложки не обойтись.
Идея
У любого файла, загруженного на сервера Telegram, есть уникальный fileId. Если мы отправим боту фотографию, то он сможет достать этот id из входящего сообщения и сохранить в базу:
Далее, когда настанет время, мы сможем использовать сохраненный fileId, чтобы отправить пост в канал.
Создаем проект
Бота будем писать на Java с использованием Spring Boot и библиотеки TelegramBots,. В качестве БД используем PostgreSQL. На Spring Initializr сгенерируем наш проект с необходимыми зависимостями:
Откроем сгенерированный проект в IDE. В build.gradle
в тегdependencies
добавим библиотеку для работы с ботами:
implementation 'org.telegram:telegrambots-spring-boot-starter:5.5.0'
Далее настроим подключение к нашей локальной БД. Для этого в application.yaml
пропишем:
spring:
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/postgres}
И создадим класс конфигурации БД:
@Configuration
public class DatabaseConfig {
@Value("${spring.datasource.url}")
private String dbUrl;
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(dbUrl);
return new HikariDataSource(config);
}
}
Создадим миграции:
databaseChangeLog:
- changeSet:
id: 1-add-record
author: ananac
changes:
- createTable:
tableName: record
columns:
- column:
name: id
type: bigint
constraints:
primaryKey: true
nullable: false
- column:
name: file_id
type: varchar(255)
constraints:
nullable: true
- column:
name: comment
type: text
constraints:
nullable: true
- column:
name: data_type
type: varchar(15)
constraints:
nullable: false
- column:
name: create_date_time
type: timestamp
constraints:
nullable: false
- column:
name: post_date_time
type: timestamp
constraints:
nullable: true
- column:
name: author
type: varchar(255)
constraints:
nullable: false
databaseChangeLog:
- include:
file: db/changelog/1-add-record.yaml
После этого можно запускать приложение, чтобы миграции накатились и в БД появилась наша таблица для хранения постов.
Создаем бота
Идем к @BotFather, с помощью команды /newbot
создаем нового бота и получаем API токен. Прописываем полученные данные в application.yaml
, заодно укажем свой userId и chatId канала, в который мы будем постить. Все это можно узнать по адресу https://api.telegram.org/bot<вставить_токен_бота>/getUpdates. Там хранятся эвенты, такие как входящие сообщения, которые еще не были обработаны ботом.
telegram:
name: botname
token: 1793090787:AaaaAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaa
chatId: -1948372984327
adminId: 265765765
Пишем логику
Реализуем сущность для созданной нами таблицы:
@Entity
@Table(name = "record")
@Data
@RequiredArgsConstructor
public class Record {
@Id
private long id;
private String fileId;
private String comment;
private String dataType;
private LocalDateTime createDateTime;
private LocalDateTime postDateTime;
private String author;
}
И JPA-репозиторий с необходимыми нам запросами:
@Repository
public interface RecordRepository extends JpaRepository<Record, Long> {
@Query("select r from Record r where r.createDateTime = (select min(r1.createDateTime) from Record r1 where r1.postDateTime = null)")
Optional<Record> getFirstRecordInQueue();
@Query("select r from Record r where r.postDateTime = (select max(r1.postDateTime) from Record r1)")
Optional<Record> getLastPostedRecord();
@Query("select count(*) from Record r where r.postDateTime = null")
long getNumberOfScheduledPosts();
@Transactional
@Modifying
@Query("delete from Record r where r.postDateTime = null")
void clear();
}
Займемся непосредственно обработчиком входящих сообщений. Создаем новый класс, отнаследованный от TelegramLongPollingBot
. В нем определяем метод, который будет обрабатывать входящие события. Мы хотим, чтобы с ботом мог работать только пользователь указанный в конфиге, поэтому добавим проверку по userId:
@Component
@Getter
@RequiredArgsConstructor
public class TelegramBotHandler extends TelegramLongPollingBot {
private final RecordRepository recordRepository;
@Value("${telegram.name}")
private String name;
@Value("${telegram.token}")
private String token;
@Value("${telegram.chatId}")
private String chatId;
@Value("${telegram.adminId}")
private Set<Long> adminId;
@Override
public String getBotUsername() {
return name;
}
@Override
public String getBotToken() {
return token;
}
@Override
public void onUpdateReceived(Update update) {
if (update.getMessage() != null) {
Long userId = update.getMessage().getFrom().getId();
if (adminId.contains(userId)) {
processMessage(update.getMessage());
} else {
reply(userId, "Permission denied");
}
}
}
private void reply(Long chatId, String text) {
try {
SendMessage sendMessage = new SendMessage();
sendMessage.setChatId(String.valueOf(chatId));
sendMessage.setText(text);
execute(sendMessage);
} catch (TelegramApiException e) {
e.printStackTrace();
}
}
}
Далее реализуем метод сохранения поста в БД. Пока делаем поддержку только для фото, но в будущем ничего не мешает расшириться на все типы вложений. Помним, что во входящем сообщении лежит несколько файлов в разном разрешении, нас интересует только самый большой:
private void processMessage(Message message) {
Long chatId = message.getChatId();
if (message.getPhoto() != null && !message.getPhoto().isEmpty()) {
Record record = buildRecord(message);
recordRepository.save(record);
reply(chatId, "Добавлено. Количество постов в отложке: " + recordRepository.getNumberOfScheduledPosts());
} else {
reply(chatId, "Принимаются только фото");
}
}
private Record buildRecord(Message message) {
Record record = new Record();
String fileId = getLargestFileId(message);
record.setFileId(fileId);
record.setComment(message.getCaption());
record.setDataType("PHOTO");
record.setId(message.getMessageId());
record.setCreateDateTime(LocalDateTime.now());
record.setAuthor(message.getFrom().getUserName());
return record;
}
private String getLargestFileId(Message message) {
return message.getPhoto().stream()
.max(Comparator.comparing(PhotoSize::getFileSize))
.orElse(null)
.getFileId();
}
Пост в базу мы добавили, перейдем к постингу. Создадим новый класс, внутри будет метод с аннотацией @Scheduled(fixedDelayString = "60000")
, что означает, что он будет запускаться каждую минуту. Не забываем также повесить аннотацию @EnableScheduling
на наш Application класс. Для интервала постинга в application.yaml
укажем, например, 120 минут.
@Component
@RequiredArgsConstructor
public class RecordService {
private final RecordRepository recordRepository;
private final TelegramBotHandler botHandler;
@Value("${schedule.postingInterval}")
private long postingInterval;
@Scheduled(fixedDelayString = "60000")
private void run() {
Optional<Record> recordToPost = recordRepository.getFirstRecordInQueue();
if (recordToPost.isPresent()) {
Optional<Record> lastPostedRecordOptional = recordRepository.getLastPostedRecord();
if (lastPostedRecordOptional.isPresent()) {
Record lastPostedRecord = lastPostedRecordOptional.get();
Duration duration = Duration.between(lastPostedRecord.getPostDateTime(), LocalDateTime.now());
if (duration.toMinutes() >= postingInterval) {
Record record = recordToPost.get();
botHandler.sendPhoto(record);
}
} else {
Record record = recordToPost.get();
botHandler.sendPhoto(record);
}
}
}
}
Метод запускается раз в минуту и первым делаем проверяет есть ли в БД неопубликованные посты. Если посты есть, то проверяется не прошло ли 120 минут с момента публикации последнего поста и на основании этого принимается решении о постинге. Также учитываем, что при первом запуске у нас не будет опубликованных постов в БД.
Далее добавим пару команд, чтобы с ботом было удобнее работать:
И реализуем их в коде. Команду для очистки сделаем с подтверждением, чтобы избежать мискликов:
private void processMessage(Message message) {
Long chatId = message.getChatId();
if (message.getPhoto() != null && !message.getPhoto().isEmpty()) {
Record record = buildRecord(message);
recordRepository.save(record);
reply(chatId, "Добавлено. Количество постов в отложке: " + recordRepository.getNumberOfScheduledPosts());
} else if (message.getText() != null) {
switch (message.getText()) {
case "/info": {
reply(chatId, "Количество постов в отложке: " + recordRepository.getNumberOfScheduledPosts());
break;
}
case "/clear": {
reply(chatId, "Чтобы очистить напиши /delete");
break;
}
case "/delete": {
recordRepository.clear();
reply(chatId, "Очищено. Количество постов в отложке: " + recordRepository.getNumberOfScheduledPosts());
break;
}
default: {
break;
}
}
} else {
reply(chatId, "Принимаются только фото");
}
}
public void sendPhoto(Record record) {
try {
SendPhoto sendPhoto = new SendPhoto();
sendPhoto.setChatId(chatId);
sendPhoto.setPhoto(new InputFile(record.getFileId()));
execute(sendPhoto);
afterPost(record);
} catch (TelegramApiException e) {
e.printStackTrace();
}
}
private void afterPost(Record record) {
record.setPostDateTime(LocalDateTime.now());
recordRepository.save(record);
}
Запуск и проверка
Поднимаем приложение и проверяем:
В дальнейшем приложение можно задеплоить в облако, например, на Heroku (что и было сделано с этим ботом) по этому гайду. С кодом можно ознакомиться здесь.