Add Telegram bot integration for pipeline processing

Introduced Telegram bot support by adding configuration properties, updating settings template, and including the TelegramBots library. Implemented TelegramPipelineBot to handle file uploads via Telegram and process them through the pipeline, with configurable timeouts and polling intervals.
This commit is contained in:
Ludy87 2025-12-08 11:01:08 +01:00
parent 7faf7e50fa
commit a32316e517
No known key found for this signature in database
GPG Key ID: 92696155E0220F94
5 changed files with 331 additions and 0 deletions

View File

@ -60,6 +60,7 @@ public class ApplicationProperties {
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
private Mail mail = new Mail();
private Telegram telegram = new Telegram();
private Premium premium = new Premium();
@ -576,6 +577,16 @@ public class ApplicationProperties {
private String from;
}
@Data
public static class Telegram {
private Boolean enabled = false;
private String botToken;
private String botUsername;
private String pipelineInboxFolder = "telegram";
private long processingTimeoutSeconds = 180;
private long pollingIntervalMillis = 2000;
}
@Data
public static class Premium {
private boolean enabled;

View File

@ -60,6 +60,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jetty'
implementation 'com.posthog.java:posthog:1.2.0'
implementation 'commons-io:commons-io:2.20.0'
implementation 'org.telegram:telegrambots:6.9.7.1'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation 'io.micrometer:micrometer-core:1.15.4'

View File

@ -0,0 +1,18 @@
package stirling.software.SPDF.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.telegram.telegrambots.meta.TelegramBotsApi;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession;
@Configuration
@ConditionalOnProperty(prefix = "telegram", name = "enabled", havingValue = "true")
public class TelegramBotConfig {
@Bean
public TelegramBotsApi telegramBotsApi() throws TelegramApiException {
return new TelegramBotsApi(DefaultBotSession.class);
}
}

View File

@ -0,0 +1,293 @@
package stirling.software.SPDF.service.telegram;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileTime;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Stream;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.TelegramBotsApi;
import org.telegram.telegrambots.meta.api.methods.GetFile;
import org.telegram.telegrambots.meta.api.methods.send.SendDocument;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Chat;
import org.telegram.telegrambots.meta.api.objects.Document;
import org.telegram.telegrambots.meta.api.objects.File;
import org.telegram.telegrambots.meta.api.objects.InputFile;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.PhotoSize;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
@Component
@ConditionalOnProperty(prefix = "telegram", name = "enabled", havingValue = "true")
public class TelegramPipelineBot extends TelegramLongPollingBot {
private final ApplicationProperties.Telegram telegramProperties;
private final RuntimePathConfig runtimePathConfig;
private final TelegramBotsApi telegramBotsApi;
public TelegramPipelineBot(
ApplicationProperties applicationProperties,
RuntimePathConfig runtimePathConfig,
TelegramBotsApi telegramBotsApi) {
super(applicationProperties.getTelegram().getBotToken());
this.telegramProperties = applicationProperties.getTelegram();
this.runtimePathConfig = runtimePathConfig;
this.telegramBotsApi = telegramBotsApi;
}
@PostConstruct
public void register() {
if (StringUtils.isAnyBlank(getBotUsername(), getBotToken())) {
log.warn("Telegram bot disabled because botToken or botUsername is not configured");
return;
}
try {
telegramBotsApi.registerBot(this);
log.info("Telegram pipeline bot registered as {}", getBotUsername());
} catch (TelegramApiException e) {
log.error("Failed to register Telegram bot", e);
}
}
@Override
public void onUpdateReceived(Update update) {
Message message = null;
if (update.hasMessage()) {
message = update.getMessage();
}
// 2) Channel posts
else if (update.hasChannelPost()) {
message = update.getChannelPost();
} else {
return;
}
Chat chat = message.getChat();
if (chat == null) {
return;
}
String chatType = chat.getType();
if (!Objects.equals(chatType, "private")
&& !Objects.equals(chatType, "group")
&& !Objects.equals(chatType, "supergroup")
&& !Objects.equals(chatType, "channel")) {
return;
}
log.info(
"Received message {} in chat {} (type={}) message {}",
message.getMessageId(),
chat.getId(),
chatType,
message.getCaption());
if (message.hasDocument() || message.hasPhoto()) {
handleIncomingFile(message);
}
}
@Override
public String getBotUsername() {
return telegramProperties.getBotUsername();
}
@Override
public String getBotToken() {
return telegramProperties.getBotToken();
}
private void handleIncomingFile(Message message) {
Long chatId = message.getChatId();
String chatType = message.getChat() != null ? message.getChat().getType() : null;
try {
// Only send status messages in private chats and groups, not in channels
if (!Objects.equals(chatType, "channel")) {
sendMessage(chatId, "File received. Starting processing in pipeline folder...");
}
PipelineFileInfo fileInfo = downloadMessageFile(message);
List<Path> outputs = waitForPipelineOutputs(fileInfo);
if (outputs.isEmpty()) {
sendMessage(
chatId,
"No results were found in the pipeline finished folder. Please check your"
+ " pipeline configuration.");
return;
}
for (Path output : outputs) {
SendDocument sendDocument = new SendDocument();
sendDocument.setChatId(chatId);
sendDocument.setDocument(
new InputFile(output.toFile(), output.getFileName().toString()));
execute(sendDocument);
}
} catch (TelegramApiException e) {
log.error("Telegram API error while processing message {}", message.getMessageId(), e);
sendMessage(chatId, "Error during processing: Telegram API error.");
} catch (IOException e) {
log.error("IO error while processing message {}", message.getMessageId(), e);
sendMessage(chatId, "Error during processing: An IO error occurred.");
} catch (Exception e) {
log.error("Unexpected error while processing message {}", message.getMessageId(), e);
sendMessage(chatId, "Error during processing: An unexpected error occurred.");
}
}
private PipelineFileInfo downloadMessageFile(Message message)
throws TelegramApiException, IOException {
if (message.hasDocument()) {
return downloadDocument(message.getDocument());
}
if (message.hasPhoto()) {
PhotoSize photo =
message.getPhoto().stream()
.max(Comparator.comparing(PhotoSize::getFileSize))
.orElseThrow(
() -> new IllegalStateException("Photo could not be loaded"));
return downloadFile(photo.getFileId(), "photo-" + message.getMessageId() + ".jpg");
}
throw new IllegalArgumentException("Unsupported file type");
}
private PipelineFileInfo downloadDocument(Document document)
throws TelegramApiException, IOException {
String filename = document.getFileName();
String name =
StringUtils.isNotBlank(filename) ? filename : document.getFileUniqueId() + ".bin";
return downloadFile(document.getFileId(), name);
}
private PipelineFileInfo downloadFile(String fileId, String originalName)
throws TelegramApiException, IOException {
GetFile getFile = new GetFile(fileId);
File telegramFile = execute(getFile);
if (telegramFile == null || StringUtils.isBlank(telegramFile.getFilePath())) {
throw new IOException("Telegram did not return a valid file path");
}
URL downloadUrl = buildDownloadUrl(telegramFile.getFilePath());
Path targetDir =
Paths.get(
runtimePathConfig.getPipelineWatchedFoldersPath(),
telegramProperties.getPipelineInboxFolder());
Files.createDirectories(targetDir);
String uniqueBaseName = FilenameUtils.getBaseName(originalName) + "-" + UUID.randomUUID();
String extension = FilenameUtils.getExtension(originalName);
String targetFilename =
extension.isBlank() ? uniqueBaseName : uniqueBaseName + "." + extension;
Path targetFile = targetDir.resolve(targetFilename);
try (InputStream inputStream = downloadUrl.openStream()) {
Files.copy(inputStream, targetFile);
}
log.info("Saved Telegram file {} to {}", originalName, targetFile);
return new PipelineFileInfo(targetFile, uniqueBaseName, Instant.now());
}
private URL buildDownloadUrl(String filePath) throws MalformedURLException {
return new URL(
String.format("https://api.telegram.org/file/bot%s/%s", getBotToken(), filePath));
}
private List<Path> waitForPipelineOutputs(PipelineFileInfo info) throws IOException {
Path finishedDir = Paths.get(runtimePathConfig.getPipelineFinishedFoldersPath());
Files.createDirectories(finishedDir);
Instant start = info.savedAt();
Duration timeout = Duration.ofSeconds(telegramProperties.getProcessingTimeoutSeconds());
Duration pollInterval = Duration.ofMillis(telegramProperties.getPollingIntervalMillis());
List<Path> foundOutputs = new ArrayList<>();
while (Duration.between(start, Instant.now()).compareTo(timeout) <= 0) {
try (Stream<Path> files = Files.walk(finishedDir, 1)) {
foundOutputs =
files.filter(Files::isRegularFile)
.filter(path -> matchesBaseName(info.uniqueBaseName(), path))
.filter(path -> isNewerThan(path, start))
.sorted(Comparator.comparing(Path::toString))
.toList();
}
if (!foundOutputs.isEmpty()) {
break;
}
try {
Thread.sleep(pollInterval.toMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
return foundOutputs;
}
private boolean matchesBaseName(String baseName, Path path) {
return path.getFileName().toString().contains(baseName);
}
private boolean isNewerThan(Path path, Instant instant) {
try {
FileTime modifiedTime = Files.getLastModifiedTime(path);
return modifiedTime.toInstant().isAfter(instant);
} catch (IOException e) {
log.debug("Could not read modification time for {}", path, e);
return false;
}
}
private void sendMessage(Long chatId, String text) {
if (chatId == null) {
return;
}
SendMessage message = new SendMessage();
message.setChatId(chatId);
message.setText(text);
try {
execute(message);
} catch (TelegramApiException e) {
log.warn("Failed to send Telegram message to {}", chatId, e);
}
}
private record PipelineFileInfo(Path originalFile, String uniqueBaseName, Instant savedAt) {}
}

View File

@ -105,6 +105,14 @@ mail:
password: '' # SMTP server password
from: '' # sender email address
telegram:
enabled: false # set to 'true' to enable Telegram bot integration
botToken: '' # Telegram bot token obtained from BotFather
botUsername: '' # Telegram bot username (without @)
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
legal:
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
privacyPolicy: https://www.stirling.com/legal/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder