From a32316e5177077029885c24ace6d2e8cc392b067 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 8 Dec 2025 11:01:08 +0100 Subject: [PATCH] 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. --- .../common/model/ApplicationProperties.java | 11 + app/core/build.gradle | 1 + .../SPDF/config/TelegramBotConfig.java | 18 ++ .../service/telegram/TelegramPipelineBot.java | 293 ++++++++++++++++++ .../src/main/resources/settings.yml.template | 8 + 5 files changed, 331 insertions(+) create mode 100644 app/core/src/main/java/stirling/software/SPDF/config/TelegramBotConfig.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index f6afa62ea..efe148d3b 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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; diff --git a/app/core/build.gradle b/app/core/build.gradle index 0ff725d3d..8fed275fc 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -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' diff --git a/app/core/src/main/java/stirling/software/SPDF/config/TelegramBotConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/TelegramBotConfig.java new file mode 100644 index 000000000..92f126ed5 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/TelegramBotConfig.java @@ -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); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java b/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java new file mode 100644 index 000000000..57c83f372 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java @@ -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 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 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 foundOutputs = new ArrayList<>(); + + while (Duration.between(start, Instant.now()).compareTo(timeout) <= 0) { + try (Stream 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) {} +} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 4c2ec003e..bdd6cca30 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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