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 72cfef1a0..f051bf622 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(); @@ -335,8 +336,8 @@ public class ApplicationProperties { throw new UnsupportedProviderException( "Logout from the provider " + registrationId - + " is not supported. " - + "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues"); + + " is not supported. Report it at" + + " https://github.com/Stirling-Tools/Stirling-PDF/issues"); }; } } @@ -536,10 +537,10 @@ public class ApplicationProperties { @Override public String toString() { return """ - Driver { - driverName='%s' - } - """ + Driver { + driverName='%s' + } + """ .formatted(driverName); } } @@ -592,6 +593,7 @@ public class ApplicationProperties { private boolean ssoAutoLogin; private CustomMetadata customMetadata = new CustomMetadata(); + @Deprecated @Data public static class CustomMetadata { private boolean autoUpdateMetadata; @@ -631,6 +633,59 @@ public class ApplicationProperties { private Boolean sslCheckServerIdentity; } + @Data + public static class Telegram { + private Boolean enabled = false; + private String botToken; + private String botUsername; + private String pipelineInboxFolder = "telegram"; + private Boolean customFolderSuffix = false; + private Boolean enableAllowUserIDs = false; + private List allowUserIDs = new ArrayList<>(); + private Boolean enableAllowChannelIDs = false; + private List allowChannelIDs = new ArrayList<>(); + private long processingTimeoutSeconds = 180; + private long pollingIntervalMillis = 2000; + private Feedback feedback = new Feedback(); + + @Data + public static class Feedback { + private General general = new General(); + private Channel channel = new Channel(); + private User user = new User(); + + @Data + public static class General { + private Boolean enabled = true; // set to 'true' to enable feedback messages + } + + @Data + public static class Channel { + private Boolean noValidDocument = + false; // set to 'true' to deny feedback messages in channels (to avoid + // spam) + private Boolean processingError = + false; // set to 'true' to deny feedback messages in channels (to avoid + // spam) + private Boolean errorMessage = + false; // set to 'true' to deny error feedback messages in channels (to + // avoid spam) + } + + @Data + public static class User { + private Boolean noValidDocument = + false; // set to 'true' to deny feedback messages to users (to avoid + // spam) + private Boolean processingError = + false; // set to 'true' to deny feedback messages to users (to avoid + // spam) + private Boolean errorMessage = + false; // set to 'true' to deny error feedback messages to users (to avoid + } + } + } + @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..f2ea1ea21 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java @@ -0,0 +1,446 @@ +package stirling.software.SPDF.service.telegram; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +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.Update; +import org.telegram.telegrambots.meta.api.objects.User; +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 static final String CHAT_PRIVATE = "private"; + private static final String CHAT_GROUP = "group"; + private static final String CHAT_SUPERGROUP = "supergroup"; + private static final String CHAT_CHANNEL = "channel"; + + private static final List ALLOWED_MIME_TYPES = List.of("application/pdf"); + + private final Object pipelinePollMonitor = new Object(); + + 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 = extractMessage(update); + if (message == null) { + return; + } + + Chat chat = message.getChat(); + if (chat == null || !isSupportedChatType(chat.getType())) { + log.debug( + "Ignoring message {}, unsupported chat type {}", + message.getMessageId(), + chat != null ? chat.getType() : "null"); + return; + } + + if (!isAuthorized(message, chat)) { + return; + } + + if (update.hasMessage() && update.getMessage().hasText()) { + String message_text = update.getMessage().getText(); + long chat_id = update.getMessage().getChatId(); + if ("/start".equals(message_text)) { + sendMessage( + chat_id, + """ + Welcome to the SPDF Telegram Bot! + + To get started, please send me a PDF document that you would like to process. + Make sure the document is in PDF format. + + Once I receive your document, I'll begin processing it through the pipeline. + """); + return; + } + } + + if (message.hasDocument()) { + handleIncomingFile(message); + return; + } + if (telegramProperties.getFeedback().getGeneral().getEnabled()) { + sendMessage( + chat.getId(), + "No valid file found in the message. Please send a document to process."); + } + } + + // --------------------------- + // Message Extraction / Chat Type + // --------------------------- + + private Message extractMessage(Update update) { + if (update.hasMessage()) return update.getMessage(); + if (update.hasChannelPost()) return update.getChannelPost(); + return null; + } + + private boolean isSupportedChatType(String type) { + return CHAT_PRIVATE.equals(type) + || CHAT_GROUP.equals(type) + || CHAT_SUPERGROUP.equals(type) + || CHAT_CHANNEL.equals(type); + } + + // --------------------------- + // Authorization + // --------------------------- + + private boolean isAuthorized(Message message, Chat chat) { + if (!(telegramProperties.getEnableAllowUserIDs() + || telegramProperties.getEnableAllowChannelIDs())) { + return true; + } + + return switch (chat.getType()) { + case CHAT_CHANNEL -> checkChannelAccess(message, chat); + case CHAT_PRIVATE -> checkUserAccess(message, chat); + case CHAT_GROUP, CHAT_SUPERGROUP -> true; // groups allowed by default + default -> false; + }; + } + + private boolean checkUserAccess(Message message, Chat chat) { + if (!telegramProperties.getEnableAllowUserIDs()) return true; + + User from = message.getFrom(); + List allow = telegramProperties.getAllowUserIDs(); + + if (allow.isEmpty()) { + log.warn("No allowed user IDs configured - allowing all users."); + return true; + } + + if (from == null || !allow.contains(from.getId())) { + log.info( + "Rejecting user {} in private chat {}", + from != null ? from.getId() : "unknown", + chat.getId()); + sendMessage(chat.getId(), "You are not authorized to use this bot."); + return false; + } + + return true; + } + + private boolean checkChannelAccess(Message message, Chat chat) { + if (!telegramProperties.getEnableAllowChannelIDs()) return true; + + Chat senderChat = message.getSenderChat(); + List allow = telegramProperties.getAllowChannelIDs(); + + if (allow.isEmpty()) { + log.warn("No allowed channel IDs configured - allowing all channels."); + return true; + } + + if (senderChat == null || !allow.contains(senderChat.getId())) { + log.info( + "Rejecting channel {} in chat {}", + senderChat != null ? senderChat.getId() : "unknown", + chat.getId()); + + sendMessage(chat.getId(), "This channel is not authorized to use this bot."); + return false; + } + + return true; + } + + // --------------------------- + // File Handling + // --------------------------- + + private void handleIncomingFile(Message message) { + Long chatId = message.getChatId(); + Document doc = message.getDocument(); + + if (doc == null) { + sendMessage(chatId, "No document found."); + return; + } + + if (doc.getMimeType() != null + && !ALLOWED_MIME_TYPES.contains(doc.getMimeType().toLowerCase())) { + sendMessage( + chatId, + "Unsupported MIME type: " + + doc.getMimeType() + + "\nAllowed: " + + String.join(", ", ALLOWED_MIME_TYPES)); + return; + } + + if (!hasJsonConfig(chatId)) { + sendMessage( + chatId, + "No JSON configuration file found in the pipeline inbox folder. Please contact" + + " the administrator."); + return; + } + + try { + if (!CHAT_CHANNEL.equals(message.getChat().getType())) { + sendMessage(chatId, "File received. Starting processing..."); + } + + PipelineFileInfo info = downloadMessageFile(message); + List outputs = waitForPipelineOutputs(info); + + if (outputs.isEmpty()) { + sendMessage( + chatId, + "No results were found in the pipeline output folder. Check" + + " configuration."); + return; + } + + for (Path file : outputs) { + SendDocument out = new SendDocument(); + out.setChatId(chatId); + out.setDocument(new InputFile(file.toFile(), file.getFileName().toString())); + execute(out); + } + + } catch (TelegramApiException e) { + log.error("Telegram API error", e); + sendMessage(chatId, "Telegram API error occurred."); + } catch (IOException e) { + log.error("IO error", e); + sendMessage(chatId, "An IO error occurred."); + } catch (Exception e) { + log.error("Unexpected error", e); + sendMessage(chatId, "Unexpected error occurred."); + } + } + + private PipelineFileInfo downloadMessageFile(Message message) + throws TelegramApiException, IOException { + Document document = message.getDocument(); + String filename = document.getFileName(); + String name = + StringUtils.isNotBlank(filename) ? filename : document.getFileUniqueId() + ".bin"; + + return downloadFile(document.getFileId(), name, message); + } + + private PipelineFileInfo downloadFile(String fileId, String originalName, Message message) + throws TelegramApiException, IOException { + + Long chatId = message.getChatId(); + + Path inboxFolder = getInboxFolder(chatId); + + GetFile getFile = new GetFile(fileId); + File tgFile = execute(getFile); + + if (tgFile == null || StringUtils.isBlank(tgFile.getFilePath())) { + throw new IOException("Telegram did not return a file path."); + } + + URL url = buildDownloadUrl(tgFile.getFilePath()); + + String base = FilenameUtils.getBaseName(originalName) + "-" + UUID.randomUUID(); + String ext = FilenameUtils.getExtension(originalName); + String outFile = ext.isBlank() ? base : base + "." + ext; + + Path targetFile = inboxFolder.resolve(outFile); + + try (InputStream in = url.openStream()) { + Files.copy(in, targetFile); + } + + log.info("Saved Telegram file {} to {}", originalName, targetFile); + return new PipelineFileInfo(targetFile, base, Instant.now()); + } + + private URL buildDownloadUrl(String filePath) throws MalformedURLException { + return URI.create("https://api.telegram.org/file/bot" + getBotToken() + "/" + filePath) + .toURL(); + } + + // --------------------------- + // Inbox-Ordner & JSON-Check + // --------------------------- + + private Path getInboxFolder(Long chatId) throws IOException { + Path baseInbox = + Paths.get( + runtimePathConfig.getPipelineWatchedFoldersPath(), + telegramProperties.getPipelineInboxFolder()); + + Files.createDirectories(baseInbox); + + Path inboxFolder = + telegramProperties.getCustomFolderSuffix() + ? baseInbox.resolve(chatId.toString()) + : baseInbox; + + Files.createDirectories(inboxFolder); + + return inboxFolder; + } + + private boolean hasJsonConfig(Long chatId) { + try { + Path inboxFolder = getInboxFolder(chatId); + try (Stream s = Files.list(inboxFolder)) { + return s.anyMatch(p -> p.toString().endsWith(".json")); + } + } catch (IOException e) { + log.error("Failed to check JSON config for chat {}", chatId, e); + return false; + } + } + + // --------------------------- + // Pipeline polling + // --------------------------- + + 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 poll = Duration.ofMillis(telegramProperties.getPollingIntervalMillis()); + List results = new ArrayList<>(); + + while (Duration.between(start, Instant.now()).compareTo(timeout) <= 0) { + try (Stream s = Files.walk(finishedDir, 1)) { + results = + s.filter(Files::isRegularFile) + .filter(path -> matchesBaseName(info.uniqueBaseName(), path)) + .filter(path -> isNewerThan(path, start)) + .sorted(Comparator.comparing(Path::toString)) + .toList(); + } + + if (!results.isEmpty()) { + break; + } + + synchronized (pipelinePollMonitor) { + try { + pipelinePollMonitor.wait(poll.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + + return results; + } + + private boolean matchesBaseName(String base, Path file) { + return file.getFileName().toString().contains(base); + } + + private boolean isNewerThan(Path path, Instant since) { + try { + return Files.getLastModifiedTime(path).toInstant().isAfter(since); + } catch (IOException e) { + log.debug("Could not read modification time for {}", path); + return false; + } + } + + // --------------------------- + // Messaging + // --------------------------- + + private void sendMessage(Long chatId, String text) { + if (chatId == null) return; + + SendMessage msg = new SendMessage(); + msg.setChatId(chatId); + msg.setText(text); + try { + execute(msg); + } catch (TelegramApiException e) { + log.warn("Failed to send message to {}", chatId, e); + } + } + + private record PipelineFileInfo(Path originalFile, String uniqueBaseName, Instant savedAt) {} + + @Override + public String getBotUsername() { + return telegramProperties.getBotUsername(); + } + + @Override + public String getBotToken() { + return telegramProperties.getBotToken(); + } +} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index dffebe1bb..f7c798241 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -112,6 +112,30 @@ mail: sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty sslCheckServerIdentity: false # enable hostname verification when using SSL/TLS +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 + customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID + enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs + allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users. + enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs + allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels. + processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request + pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages + feedback: + general: + enabled: true # set to 'true' to enable feedback messages + channel: + noValidDocument: false # set to 'true' to deny feedback messages in channels (to avoid spam) + processingError: false # set to 'true' to deny feedback messages in channels (to avoid spam) + errorMessage: false # set to 'true' to deny error messages in channels (to avoid spam) + user: + noValidDocument: false # set to 'true' to deny feedback messages to users (to avoid spam) + processingError: false # set to 'true' to deny feedback messages to users (to avoid spam) + errorMessage: false # set to 'true' to deny error messages to users (to avoid spam) + 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