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