From e7b030e6b560e6c04e43ba3f7da091c1d7a4a2bd Mon Sep 17 00:00:00 2001 From: Ludy Date: Tue, 13 Jan 2026 23:14:59 +0100 Subject: [PATCH] Add Telegram bot integration for pipeline processing (#5185) # Description of Changes This pull request introduces Telegram bot integration to the application, enabling users to send files via Telegram for processing through the pipeline. The main changes add configuration options, dependency management, and a new service for handling Telegram interactions. **Telegram bot integration:** * Added a new `TelegramPipelineBot` service (`TelegramPipelineBot.java`) that listens for incoming Telegram messages, downloads attached files or photos, places them in a pipeline inbox folder, waits for processing results, and sends the output files back to the user. The service includes error handling and status messaging. * Introduced a `TelegramBotConfig` configuration class to initialize and register the Telegram bot only when enabled via application properties. * Added a new `Telegram` configuration section to `ApplicationProperties` and the `settings.yml.template`, supporting options like enabling/disabling the bot, bot token/username, pipeline folder, processing timeout, and polling interval. [[1]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R63) [[2]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R580-R589) [[3]](diffhunk://#diff-12f23603ae35319a3ea08f91b6340d5d935216941fda2e69d2df1b6cd22a63f2R108-R115) **Dependency management:** * Added the `org.telegram:telegrambots` library to the project dependencies to support Telegram bot functionality. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../common/model/ApplicationProperties.java | 113 +++- app/core/build.gradle | 1 + .../SPDF/config/TelegramBotConfig.java | 18 + .../SPDF/service/telegram/FeedbackEnum.java | 20 + .../service/telegram/TelegramPipelineBot.java | 524 ++++++++++++++++++ .../src/main/resources/settings.yml.template | 100 ++-- .../api/AdminSettingsController.java | 4 +- .../public/locales/de-DE/translation.toml | 153 ++++- .../public/locales/en-GB/translation.toml | 85 +++ .../config/configSections/ProviderCard.tsx | 45 +- .../configSections/providerDefinitions.ts | 230 +++++++- .../core/components/shared/config/types.ts | 3 +- .../AdminConnectionsSection.tsx | 101 +++- 13 files changed, 1313 insertions(+), 84 deletions(-) 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/FeedbackEnum.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 58c120329f..cc9a3bbfc4 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 @@ -61,6 +61,7 @@ public class ApplicationProperties { private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated(); private Mail mail = new Mail(); + private Telegram telegram = new Telegram(); private Premium premium = new Premium(); @@ -551,10 +552,10 @@ public class ApplicationProperties { @Override public String toString() { return """ - Driver { - driverName='%s' - } - """ + Driver { + driverName='%s' + } + """ .formatted(driverName); } } @@ -607,6 +608,7 @@ public class ApplicationProperties { private boolean ssoAutoLogin; private CustomMetadata customMetadata = new CustomMetadata(); + @Deprecated @Data public static class CustomMetadata { private boolean autoUpdateMetadata; @@ -614,16 +616,23 @@ public class ApplicationProperties { private String creator; private String producer; + @Deprecated public String getCreator() { return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator; } + @Deprecated public String getProducer() { return producer == null || producer.trim().isEmpty() ? "Stirling-PDF" : producer; } } } + /** + * Mail server configuration properties. + * + * @since 0.46.1 + */ @Data public static class Mail { private boolean enabled; @@ -646,6 +655,102 @@ public class ApplicationProperties { private Boolean sslCheckServerIdentity; } + /** + * Telegram bot configuration properties. + * + * @since 2.2.x + */ + @Data + public static class Telegram { + private Boolean enabled = false; + @ToString.Exclude 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(); + + /** + * Configuration for feedback messages sent by the Telegram bot. + * + * @since 2.2.x + */ + @Data + public static class Feedback { + private Channel channel = new Channel(); + private User user = new User(); + + /** + * Channel-specific feedback settings. + * + * @since 2.2.x + */ + @Data + public static class Channel { + /** + * Set to {@code false} to hide/suppress "no valid document" feedback messages to + * the channel (to avoid spam). + */ + private Boolean noValidDocument = true; + + /** + * Set to {@code false} to hide/suppress generic error feedback messages to the + * channel (to avoid spam). + */ + private Boolean errorMessage = true; + + /** + * Set to {@code false} to hide/suppress processing error feedback messages to the + * channel (to avoid spam). + */ + private Boolean errorProcessing = true; + + /** + * Set to {@code false} to hide/suppress "processing" feedback messages to the + * channel (to avoid spam). + */ + private Boolean processing = true; + } + + /** + * User-specific feedback settings. + * + * @since 2.2.x + */ + @Data + public static class User { + /** + * Set to {@code false} to hide/suppress "no valid document" feedback messages to + * users (to avoid spam). + */ + private Boolean noValidDocument = true; + + /** + * Set to {@code false} to hide/suppress generic error feedback messages to users + * (to avoid spam). + */ + private Boolean errorMessage = true; + + /** + * Set to {@code false} to hide/suppress processing error feedback messages to users + * (to avoid spam). + */ + private Boolean errorProcessing = true; + + /** + * Set to {@code false} to hide/suppress "processing" feedback messages to users (to + * avoid spam). + */ + private Boolean processing = true; + } + } + } + @Data public static class Premium { private boolean enabled; diff --git a/app/core/build.gradle b/app/core/build.gradle index 86c002ff86..61c7b563c7 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -59,6 +59,7 @@ dependencies { implementation project(':common') implementation 'org.springframework.boot:spring-boot-starter-jetty' implementation 'com.posthog.java:posthog:1.2.0' + implementation 'org.telegram:telegrambots:6.9.7.1' implementation 'commons-io:commons-io:2.21.0' implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion" implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion" 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 0000000000..92f126ed5b --- /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/FeedbackEnum.java b/app/core/src/main/java/stirling/software/SPDF/service/telegram/FeedbackEnum.java new file mode 100644 index 0000000000..bd57e7a465 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/telegram/FeedbackEnum.java @@ -0,0 +1,20 @@ +package stirling.software.SPDF.service.telegram; + +/** + * Enumeration representing different feedback types for Telegram service. + * + * @since 2.2.x + */ +public enum FeedbackEnum { + /** Indicates that the provided document is not valid. */ + NO_VALID_DOCUMENT, + + /** Represents a generic error message. */ + ERROR_MESSAGE, + + /** Indicates that an error occurred during processing. */ + ERROR_PROCESSING, + + /** Indicates that processing is ongoing. */ + PROCESSING +} 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 0000000000..798f880a90 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/telegram/TelegramPipelineBot.java @@ -0,0 +1,524 @@ +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.URISyntaxException; +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.Set; +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; + +/** + * Telegram bot that processes incoming files through a defined pipeline. + * + * @since 2.2.x + */ +@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 Set SUPPORTED_CHAT_TYPES = + Set.of(CHAT_PRIVATE, CHAT_GROUP, CHAT_SUPERGROUP, CHAT_CHANNEL); + + private static final Set ALLOWED_MIME_TYPES = Set.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.info( + "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 messageText = update.getMessage().getText(); + long chatId = update.getMessage().getChatId(); + if ("/start".equals(messageText)) { + sendMessage( + chatId, + """ + 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 (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chat.getType())) { + sendMessage( + chat.getId(), + "No valid file found in the message. Please send a document to process."); + } + } + + private boolean feedback(FeedbackEnum feedbackEnum, String chatType) { + return switch (feedbackEnum) { + case NO_VALID_DOCUMENT -> + switch (chatType) { + case CHAT_CHANNEL -> + telegramProperties.getFeedback().getChannel().getNoValidDocument(); + case CHAT_PRIVATE -> + telegramProperties.getFeedback().getUser().getNoValidDocument(); + default -> true; + }; + case ERROR_MESSAGE -> + switch (chatType) { + case CHAT_CHANNEL -> + telegramProperties.getFeedback().getChannel().getErrorMessage(); + case CHAT_PRIVATE -> + telegramProperties.getFeedback().getUser().getErrorMessage(); + default -> true; + }; + case ERROR_PROCESSING -> + switch (chatType) { + case CHAT_CHANNEL -> + telegramProperties.getFeedback().getChannel().getErrorProcessing(); + case CHAT_PRIVATE -> + telegramProperties.getFeedback().getUser().getErrorProcessing(); + default -> true; + }; + case PROCESSING -> + switch (chatType) { + case CHAT_CHANNEL -> + telegramProperties.getFeedback().getChannel().getProcessing(); + case CHAT_PRIVATE -> + telegramProperties.getFeedback().getUser().getProcessing(); + default -> true; + }; + default -> true; + }; + } + + // --------------------------- + // 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 type != null && SUPPORTED_CHAT_TYPES.contains(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()); + if (feedback(FeedbackEnum.ERROR_MESSAGE, chat.getType())) { + 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()); + if (feedback(FeedbackEnum.ERROR_MESSAGE, chat.getType())) { + 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(); + String chatType = message.getChat().getType(); + + if (doc == null) { + if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chatType)) { + sendMessage(chatId, "No document found."); + } + return; + } + + if (doc.getMimeType() != null + && !ALLOWED_MIME_TYPES.contains(doc.getMimeType().toLowerCase())) { + if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chatType)) { + sendMessage( + chatId, + "Unsupported MIME type: " + + doc.getMimeType() + + "\nAllowed: " + + String.join(", ", ALLOWED_MIME_TYPES)); + } + return; + } + + if (!hasJsonConfig(chatId)) { + if (feedback(FeedbackEnum.ERROR_PROCESSING, chatType)) { + sendMessage( + chatId, + "No JSON configuration file found in the pipeline inbox folder. Please" + + " contact the administrator."); + } + return; + } + + try { + if (!CHAT_CHANNEL.equalsIgnoreCase(chatType) + && feedback(FeedbackEnum.PROCESSING, chatType)) { + sendMessage(chatId, "File received. Starting processing..."); + } + + PipelineFileInfo info = downloadMessageFile(message); + List outputs = waitForPipelineOutputs(info); + + if (outputs.isEmpty()) { + if (feedback(FeedbackEnum.ERROR_PROCESSING, chatType)) { + 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); + if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) { + sendMessage(chatId, "Telegram API error occurred."); + } + } catch (IOException e) { + log.error("IO error", e); + if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) { + sendMessage(chatId, "An IO error occurred."); + } + } catch (Exception e) { + log.error("Unexpected error", e); + if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) { + 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 { + try { + URI uri = + new URI( + "https", + "api.telegram.org", + "/file/bot" + getBotToken() + "/" + filePath, + null); + return uri.toURL(); + } catch (URISyntaxException e) { + throw new MalformedURLException("Failed to build Telegram download URL"); + } catch (MalformedURLException e) { + MalformedURLException sanitized = + new MalformedURLException("Failed to build Telegram download URL"); + sanitized.initCause(e); + throw sanitized; + } + } + + // --------------------------- + // 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.list(finishedDir)) { + 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.info("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 5f68e65e56..bb0cbac5d2 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -16,30 +16,30 @@ security: loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2) initialLogin: - username: '' # initial username for the first login - password: '' # initial password for the first login + username: "" # initial username for the first login + password: "" # initial password for the first login oauth2: enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work) client: keycloak: - issuer: '' # URL of the Keycloak realm's OpenID Connect Discovery endpoint - clientId: '' # client ID for Keycloak OAuth2 - clientSecret: '' # client secret for Keycloak OAuth2 + issuer: "" # URL of the Keycloak realm's OpenID Connect Discovery endpoint + clientId: "" # client ID for Keycloak OAuth2 + clientSecret: "" # client secret for Keycloak OAuth2 scopes: openid, profile, email # scopes for Keycloak OAuth2 useAsUsername: preferred_username # field to use as the username for Keycloak OAuth2. Available options are: [email | name | given_name | family_name | preferred_name] google: - clientId: '' # client ID for Google OAuth2 - clientSecret: '' # client secret for Google OAuth2 + clientId: "" # client ID for Google OAuth2 + clientSecret: "" # client secret for Google OAuth2 scopes: email, profile # scopes for Google OAuth2 useAsUsername: email # field to use as the username for Google OAuth2. Available options are: [email | name | given_name | family_name] github: - clientId: '' # client ID for GitHub OAuth2 - clientSecret: '' # client secret for GitHub OAuth2 + clientId: "" # client ID for GitHub OAuth2 + clientSecret: "" # client secret for GitHub OAuth2 scopes: read:user # scope for GitHub OAuth2 useAsUsername: login # field to use as the username for GitHub OAuth2. Available options are: [email | login | name] - issuer: '' # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint - clientId: '' # client ID from your Provider - clientSecret: '' # client secret from your Provider + issuer: "" # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint + clientId: "" # client ID from your Provider + clientSecret: "" # client secret from your Provider autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin useAsUsername: email # default is 'email'; custom fields can be used as the username @@ -47,14 +47,14 @@ security: provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak' saml2: enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) - provider: '' # The name of your Provider + provider: "" # The name of your Provider autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider - idpIssuer: '' # The ID of your Provider + idpIssuer: "" # The ID of your Provider idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair @@ -110,21 +110,45 @@ mail: enableInvites: false # set to 'true' to enable email invites for user management (requires mail.enabled and security.enableLogin) host: smtp.example.com # SMTP server hostname port: 587 # SMTP server port - username: '' # SMTP server username - password: '' # SMTP server password - from: '' # sender email address + username: "" # SMTP server username + password: "" # SMTP server password + from: "" # sender email address startTlsEnable: true # enable STARTTLS (explicit TLS upgrade after connecting) when supported by the SMTP server startTlsRequired: false # require STARTTLS; connection fails if the upgrade command is not supported sslEnable: false # enable SSL/TLS wrapper for implicit TLS (typically used with port 465) - sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty + 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: + channel: + noValidDocument: true # set to 'false' to hide/suppress feedback messages in channels (to avoid spam) + errorProcessing: true # set to 'false' to hide/suppress feedback messages in channels (to avoid spam) + errorMessage: true # set to 'false' to hide/suppress error messages in channels (to avoid spam) + processing: true # set to 'false' to hide/suppress processing messages in channels (to avoid spam) + user: + noValidDocument: true # set to 'false' to hide/suppress feedback messages to users (to avoid spam) + errorProcessing: true # set to 'false' to hide/suppress feedback messages to users (to avoid spam) + errorMessage: true # set to 'false' to hide/suppress error messages to users (to avoid spam) + processing: true # set to 'false' to hide/suppress processing 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 - accessibilityStatement: '' # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder - cookiePolicy: '' # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder - impressum: '' # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder + accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder + cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder + impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder system: defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc) @@ -143,8 +167,8 @@ system: disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) maxDPI: 500 # Maximum allowed DPI for PDF to image conversion corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173' - backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. - frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. + backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. + frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured. mobileScannerSettings: convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is. @@ -162,14 +186,14 @@ system: level: MEDIUM # Security level: MAX (whitelist only), MEDIUM (block internal networks), OFF (no restrictions) allowedDomains: [] # Whitelist of allowed domains (e.g. ['cdn.example.com', 'images.google.com']) blockedDomains: [] # Additional domains to block (e.g. ['evil.com', 'malicious.org']) - internalTlds: ['.local', '.internal', '.corp', '.home'] # Block domains with these TLD patterns + internalTlds: [".local", ".internal", ".corp", ".home"] # Block domains with these TLD patterns blockPrivateNetworks: true # Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x) blockLocalhost: true # Block localhost and loopback addresses (127.x.x.x, ::1) blockLinkLocal: true # Block link-local addresses (169.254.x.x, fe80::/10) blockCloudMetadata: true # Block cloud provider metadata endpoints (169.254.169.254) datasource: enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration - customDatabaseUrl: '' # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used + customDatabaseUrl: "" # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used username: postgres # set the database username password: postgres # set the database password type: postgresql # the type of the database to set (e.g. 'h2', 'postgresql') @@ -178,29 +202,29 @@ system: name: postgres # set the name of your database. Should match the name of the database you create customPaths: pipeline: - watchedFoldersDir: '' # Defaults to /pipeline/watchedFolders - finishedFoldersDir: '' # Defaults to /pipeline/finishedFolders + watchedFoldersDir: "" # Defaults to /pipeline/watchedFolders + finishedFoldersDir: "" # Defaults to /pipeline/finishedFolders operations: - weasyprint: '' # Defaults to /opt/venv/bin/weasyprint - unoconvert: '' # Defaults to /opt/venv/bin/unoconvert - calibre: '' # Defaults to /usr/bin/ebook-convert - ocrmypdf: '' # Defaults to /usr/bin/ocrmypdf - soffice: '' # Defaults to /usr/bin/soffice - fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB". + weasyprint: "" # Defaults to /opt/venv/bin/weasyprint + unoconvert: "" # Defaults to /opt/venv/bin/unoconvert + calibre: "" # Defaults to /usr/bin/ebook-convert + ocrmypdf: "" # Defaults to /usr/bin/ocrmypdf + soffice: "" # Defaults to /usr/bin/soffice + fileUploadLimit: "" # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB". tempFileManagement: - baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf - libreofficeDir: '' # Defaults to tempFileManagement.baseTmpDir/libreoffice - systemTempDir: '' # Only used if cleanupSystemTemp is true + baseTmpDir: "" # Defaults to java.io.tmpdir/stirling-pdf + libreofficeDir: "" # Defaults to tempFileManagement.baseTmpDir/libreoffice + systemTempDir: "" # Only used if cleanupSystemTemp is true prefix: stirling-pdf- # Prefix for temp file names maxAgeHours: 24 # Maximum age in hours before temp files are cleaned up cleanupIntervalMinutes: 30 # How often to run cleanup (in minutes) startupCleanup: true # Clean up old temp files on startup cleanupSystemTemp: false # Whether to clean broader system temp directory databaseBackup: - cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight + cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight ui: - appNameNavbar: '' # name displayed on the navigation bar + appNameNavbar: "" # name displayed on the navigation bar logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo) languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index f250d60ccf..28a580c746 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -581,6 +581,7 @@ public class AdminSettingsController { case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor(); case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline(); case "legal" -> applicationProperties.getLegal(); + case "telegram" -> applicationProperties.getTelegram(); default -> null; }; } @@ -602,7 +603,8 @@ public class AdminSettingsController { "processexecutor", "autoPipeline", "autopipeline", - "legal"); + "legal", + "telegram"); // Pattern to validate safe property paths - only alphanumeric, dots, and underscores private static final Pattern SAFE_KEY_PATTERN = diff --git a/frontend/public/locales/de-DE/translation.toml b/frontend/public/locales/de-DE/translation.toml index dd18085b5a..41eb3f4a27 100644 --- a/frontend/public/locales/de-DE/translation.toml +++ b/frontend/public/locales/de-DE/translation.toml @@ -82,7 +82,7 @@ incorrectPasswordMessage = "Das Passwort ist falsch." usernameExistsMessage = "Neuer Benutzername existiert bereits." invalidUsernameMessage = "Ungültiger Benutzername. Der Benutzername darf nur Buchstaben, Zahlen und die folgenden Sonderzeichen @._+- enthalten oder muss eine gültige E-Mail-Adresse sein." invalidPasswordMessage = "Das Passwort darf nicht leer sein und kein Leerzeichen am Anfang und Ende haben." -confirmPasswordErrorMessage = "„Neues Passwort“ und „Neues Passwort bestätigen“ müssen übereinstimmen." +confirmPasswordErrorMessage = "\"Neues Passwort\" und \"Neues Passwort bestätigen\" müssen übereinstimmen." deleteCurrentUserMessage = "Der aktuell angemeldete Benutzer kann nicht gelöscht werden." deleteUsernameExistsMessage = "Der Benutzername existiert nicht und kann nicht gelöscht werden." downgradeCurrentUserMessage = "Die Rolle des aktuellen Benutzers kann nicht herabgestuft werden" @@ -363,6 +363,7 @@ advanced = "Erweitert" title = "Sicherheit & Authentifizierung" security = "Sicherheit" connections = "Verbindungen" +telegram = "Telegram" [settings.licensingAnalytics] title = "Lizenzierung & Analytics" @@ -2316,7 +2317,7 @@ colorLabel = "Textfarbe" heading = "Gespeicherte Unterschriften" description = "Gespeicherte Unterschriften jederzeit wiederverwenden." emptyTitle = "Noch keine gespeicherten Unterschriften" -emptyDescription = "Zeichnen, laden oder tippen Sie oben eine Unterschrift und wählen Sie dann „In Bibliothek speichern“, um bis zu {{max}} Favoriten bereitzuhalten." +emptyDescription = "Zeichnen, laden oder tippen Sie oben eine Unterschrift und wählen Sie dann \"In Bibliothek speichern\", um bis zu {{max}} Favoriten bereitzuhalten." limitTitle = "Limit erreicht" limitDescription = "Entfernen Sie eine gespeicherte Unterschrift, bevor Sie neue hinzufügen (max. {{max}})." carouselPosition = "{{current}} von {{total}}" @@ -2526,7 +2527,7 @@ title = "Über Anmerkungen entfernen" description = "Dieses Werkzeug entfernt alle Anmerkungen (Kommentare, Hervorhebungen, Notizen usw.) aus Ihren PDF-Dokumenten." [removeAnnotations.tooltip.header] -title = "Über „Anmerkungen entfernen“" +title = "Über \"Anmerkungen entfernen\"" [removeAnnotations.tooltip.description] title = "Funktion" @@ -3849,11 +3850,6 @@ failed = "Ein Fehler ist beim Komprimieren der PDF aufgetreten." _value = "Kompressionseinstellungen" 1 = "1-3 PDF-Komprimierung,
4-6 Leichte Bildkomprimierung,
7-9 Intensive Bildkomprimierung verringert die Bildqualität dramatisch" -[compress.compressionLevel] -range1to3 = "Lower values preserve quality but result in larger files" -range4to6 = "Medium compression with moderate quality reduction" -range7to9 = "Higher values reduce file size significantly but may reduce image clarity" - [compress.lineArt] description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction." detailLevel = "Detail level" @@ -3867,6 +3863,11 @@ unavailable = "ImageMagick is not installed or enabled on this server" [compress.linearize] label = "Linearize PDF for fast web viewing" +[compress.compressionLevel] +range1to3 = "Lower values preserve quality but result in larger files" +range4to6 = "Medium compression with moderate quality reduction" +range7to9 = "Higher values reduce file size significantly but may reduce image clarity" + [decrypt] passwordPrompt = "Diese Datei ist passwortgeschützt. Bitte geben Sie das Passwort ein:" cancelled = "Vorgang für PDF abgebrochen: {0}" @@ -4020,7 +4021,7 @@ showPreferencesBtn = "Einstellungen verwalten" [cookieBanner.popUp.description] 1 = "Wir verwenden Cookies und andere Technologien, damit Stirling PDF für Sie besser funktioniert. Dies hilft uns dabei, unsere Tools zu verbessern und weiterhin Funktionen zu entwickeln, die Ihnen gefallen werden." -2 = "Wenn Sie dies nicht möchten, klicken Sie auf „Nein, Danke“. Dadurch werden nur die unbedingt erforderlichen Cookies aktiviert, die für einen reibungslosen Ablauf erforderlich sind." +2 = "Wenn Sie dies nicht möchten, klicken Sie auf \"Nein, Danke\". Dadurch werden nur die unbedingt erforderlichen Cookies aktiviert, die für einen reibungslosen Ablauf erforderlich sind." [cookieBanner.preferencesModal] title = "Einwilligungszentrum" @@ -4119,13 +4120,13 @@ toggleSidebar = "Seitenleiste umschalten" exportSelected = "Ausgewählte Seiten exportieren" toggleAnnotations = "Anmerkungen ein-/ausblenden" print = "PDF drucken" -draw = "Zeichnen" -save = "Speichern" saveChanges = "Änderungen speichern" annotations = "Annotations" applyRedactionsFirst = "Apply redactions first" +draw = "Draw" exitRedaction = "Exit Redaction Mode" redact = "Redact" +save = "Save" [search] title = "PDF durchsuchen" @@ -4574,6 +4575,102 @@ pageFormatA4 = "A4 (210×297mm)" pageFormatKeep = "Keep (Original Dimensions)" pageFormatLetter = "Letter (8.5×11in)" +[admin.settings.telegram] +title = "Telegram-Bot" +description = "Telegram-Bot-Anbindung, Zugriffskontrollen und Feedback-Verhalten konfigurieren." + +[admin.settings.telegram.enabled] +label = "Telegram-Bot aktivieren" +description = "Erlaubt Benutzern die Interaktion mit Stirling PDF über den konfigurierten Telegram-Bot." + +[admin.settings.telegram.botUsername] +label = "Bot-Benutzername" +description = "Der öffentliche Benutzername Ihres Telegram-Bots." + +[admin.settings.telegram.botToken] +label = "Bot-Token" +description = "API-Token, das von BotFather für Ihren Telegram-Bot bereitgestellt wird." + +[admin.settings.telegram.pipelineInboxFolder] +label = "Posteingangsordner" +description = "Ordner innerhalb des Pipeline-Verzeichnisses, in dem eingehende Telegram-Dateien gespeichert werden." + +[admin.settings.telegram.customFolderSuffix] +label = "Benutzerdefinierten Ordner-Suffix verwenden" +description = "Hängt die Chat-ID an eingehende Dateiordner an, um Uploads pro Chat zu isolieren." + +[admin.settings.telegram.accessControl] +title = "Zugriffskontrolle" +description = "Einschränken, welche Benutzer oder Kanäle mit dem Bot interagieren dürfen." + +[admin.settings.telegram.enableAllowUserIDs] +label = "Bestimmte Benutzer-IDs erlauben" +description = "Wenn aktiviert, dürfen nur aufgeführte Benutzer-IDs den Bot verwenden." + +[admin.settings.telegram.allowUserIDs] +label = "Erlaubte Benutzer-IDs" +description = "Telegram-Benutzer-IDs, die mit dem Bot interagieren dürfen." +placeholder = "Benutzer-ID hinzufügen und Enter drücken" + +[admin.settings.telegram.enableAllowChannelIDs] +label = "Bestimmte Kanal-IDs erlauben" +description = "Wenn aktiviert, dürfen nur aufgeführte Kanal-IDs den Bot verwenden." + +[admin.settings.telegram.allowChannelIDs] +label = "Erlaubte Kanal-IDs" +description = "Telegram-Kanal-IDs, die mit dem Bot interagieren dürfen." +placeholder = "Kanal-ID hinzufügen und Enter drücken" + +[admin.settings.telegram.processing] +title = "Verarbeitung" +description = "Polling-Intervalle und Verarbeitungs-Timeouts für Telegram-Uploads steuern." + +[admin.settings.telegram.processingTimeoutSeconds] +label = "Verarbeitungs-Timeout (Sekunden)" +description = "Maximale Wartezeit für einen Verarbeitungsjob, bevor ein Fehler gemeldet wird." + +[admin.settings.telegram.pollingIntervalMillis] +label = "Polling-Intervall (ms)" +description = "Intervall zwischen Prüfungen auf neue Telegram-Updates." + +[admin.settings.telegram.feedback] +title = "Feedback-Nachrichten" +description = "Festlegen, wann der Bot Feedback an Benutzer und Kanäle senden soll." + +[admin.settings.telegram.feedback.general.enabled] +label = "Feedback aktivieren" +description = "Legt fest, ob der Bot überhaupt Feedback-Nachrichten sendet." + +[admin.settings.telegram.feedback.channel] +title = "Kanal-Feedback-Regeln" + +[admin.settings.telegram.feedback.channel.noValidDocument] +label = "\"Kein gültiges Dokument\" einblenden (Kanal)" +description = "Sendet die Meldung \"Kein gültiges Dokument\" bei Kanal-Uploads." + +[admin.settings.telegram.feedback.channel.errorProcessing] +label = "Verarbeitungsfehler einblenden (Kanal)" +description = "Sendet Verarbeitungsfehlermeldungen an Kanäle senden." + +[admin.settings.telegram.feedback.channel.errorMessage] +label = "Fehlermeldungen einblenden (Kanal)" +description = "Detaillierte Fehlermeldungen für Kanäle einblenden." + +[admin.settings.telegram.feedback.user] +title = "Benutzer-Feedback-Regeln" + +[admin.settings.telegram.feedback.user.noValidDocument] +label = "\"Kein gültiges Dokument\" einblenden (Benutzer)" +description = "Sendet die Meldung \"Kein gültiges Dokument\" bei Benutzer-Uploads." + +[admin.settings.telegram.feedback.user.errorProcessing] +label = "Verarbeitungsfehler einblenden (Benutzer)" +description = "Sendet Verarbeitungsfehlermeldungen an Benutzer." + +[admin.settings.telegram.feedback.user.errorMessage] +label = "Fehlermeldungen einblenden (Benutzer)" +description = "Detaillierte Fehlermeldungen für Benutzer einblenden." + [admin.settings.database] title = "Datenbank" description = "Benutzerdefinierte Datenbankverbindungseinstellungen für Enterprise-Bereitstellungen konfigurieren." @@ -4842,11 +4939,11 @@ description = "Optionale Funktionen und Features konfigurieren." [admin.settings.features.serverCertificate] label = "Serverzertifikat" -description = "Serverseitige Zertifikatserstellung für die Funktion „Mit Stirling-PDF signieren“ konfigurieren" +description = "Serverseitige Zertifikatserstellung für die Funktion \"Mit Stirling-PDF signieren\" konfigurieren" [admin.settings.features.serverCertificate.enabled] label = "Serverzertifikat aktivieren" -description = "Serverseitiges Zertifikat für die Option „Mit Stirling-PDF signieren“ aktivieren" +description = "Serverseitiges Zertifikat für die Option \"Mit Stirling-PDF signieren\" aktivieren" [admin.settings.features.serverCertificate.organizationName] label = "Name der Organisation" @@ -5328,13 +5425,13 @@ confirmPrompt = "Sind Sie sicher, dass Sie fortfahren möchten?" confirmCta = "Schlüssel aktualisieren" [config.apiKeys.alert] -apiKeyErrorTitle = "API-Schlüssel-Fehler" -failedToCreateApiKey = "API-Schlüssel konnte nicht erstellt werden." -failedToRetrieveApiKey = "API-Schlüssel konnte nicht aus der Antwort abgerufen werden." -failedToFetchApiKey = "API-Schlüssel konnte nicht abgerufen werden." -apiKeyRefreshed = "API-Schlüssel aktualisiert" -apiKeyRefreshedBody = "Ihr API-Schlüssel wurde erfolgreich aktualisiert." -failedToRefreshApiKey = "API-Schlüssel konnte nicht aktualisiert werden." +apiKeyErrorTitle = "API Key Error" +apiKeyRefreshed = "API Key Refreshed" +apiKeyRefreshedBody = "Your API key has been successfully refreshed." +failedToCreateApiKey = "Failed to create API key." +failedToFetchApiKey = "Failed to fetch API key." +failedToRefreshApiKey = "Failed to refresh API key." +failedToRetrieveApiKey = "Failed to retrieve API key from response." [AddAttachmentsRequest] attachments = "Anhänge auswählen" @@ -5346,7 +5443,7 @@ selectedFiles = "Ausgewählte Dateien" submit = "Anhänge hinzufügen" [AddAttachmentsRequest.tooltip.header] -title = "Über „Anhänge hinzufügen“" +title = "Über \"Anhänge hinzufügen\"" [AddAttachmentsRequest.tooltip.description] title = "Funktion" @@ -5355,7 +5452,7 @@ title = "Funktion" title = "Anhangs-Ergebnisse" [AddAttachmentsRequest.error] -failed = "Operation „Anhänge hinzufügen“ fehlgeschlagen" +failed = "Operation \"Anhänge hinzufügen\" fehlgeschlagen" [addAttachments.error] failed = "Beim Hinzufügen von Anhängen zur PDF ist ein Fehler aufgetreten." @@ -6473,7 +6570,6 @@ pen = "Pen" polygon = "Polygon" rectangle = "Rectangle" redo = "Redo" -saveChanges = "Save Changes" saveFailed = "Unable to save copy" saveReady = "Download ready" savingCopy = "Preparing download..." @@ -6496,11 +6592,17 @@ title = "Annotate" underline = "Underline" undo = "Undo" unsupportedType = "This annotation type is not fully supported for editing." +saveChanges = "Save Changes" [footer] discord = "Discord" issues = "GitHub" +[textAlign] +center = "Center" +left = "Left" +right = "Right" + [mobileScanner] addToBatch = "Add to Batch" back = "Back" @@ -6555,8 +6657,3 @@ pollingError = "Error checking for files" sessionCreateError = "Failed to create session" sessionId = "Session ID" title = "Upload from Mobile" - -[textAlign] -center = "Center" -left = "Left" -right = "Right" diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 499b407a91..8d6c49d1a6 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -367,6 +367,7 @@ advanced = "Advanced" title = "Security & Authentication" security = "Security" connections = "Connections" +telegram = "Telegram" [settings.licensingAnalytics] title = "Licensing & Analytics" @@ -4633,6 +4634,90 @@ description = "Automatically create user accounts on first SAML2 login" label = "Block Registration" description = "Prevent new user registration via SAML2" +[admin.settings.telegram] +title = "Telegram Bot" +description = "Configure Telegram bot connectivity, access controls, and feedback behavior." + +[admin.settings.telegram.enabled] +label = "Enable Telegram Bot" +description = "Allow users to interact with Stirling PDF through your configured Telegram bot." + +[admin.settings.telegram.botUsername] +label = "Bot Username" +description = "The public username of your Telegram bot." + +[admin.settings.telegram.botToken] +label = "Bot Token" +description = "API token provided by BotFather for your Telegram bot." + +[admin.settings.telegram.pipelineInboxFolder] +label = "Inbox Folder" +description = "Folder under the pipeline directory where incoming Telegram files are stored." + +[admin.settings.telegram.customFolderSuffix] +label = "Use Custom Folder Suffix" +description = "Append the chat ID to incoming file folders to isolate uploads per chat." + +[admin.settings.telegram.accessControl] +title = "Access Control" +description = "Restrict which users or channels can interact with the bot." + +[admin.settings.telegram.enableAllowUserIDs] +label = "Allow Specific User IDs" +description = "When enabled, only listed user IDs can use the bot." + +[admin.settings.telegram.allowUserIDs] +label = "Allowed User IDs" +description = "Enter Telegram user IDs allowed to interact with the bot." +placeholder = "Add user ID and press enter" + +[admin.settings.telegram.enableAllowChannelIDs] +label = "Allow Specific Channel IDs" +description = "When enabled, only listed channel IDs can use the bot." + +[admin.settings.telegram.allowChannelIDs] +label = "Allowed Channel IDs" +description = "Enter Telegram channel IDs allowed to interact with the bot." +placeholder = "Add channel ID and press enter" + +[admin.settings.telegram.processing] +title = "Processing" +description = "Control polling intervals and processing timeouts for Telegram uploads." + +[admin.settings.telegram.processingTimeoutSeconds] +label = "Processing Timeout (seconds)" +description = "Maximum time to wait for a processing job before reporting an error." + +[admin.settings.telegram.pollingIntervalMillis] +label = "Polling Interval (ms)" +description = "Interval between checks for new Telegram updates." + +[admin.settings.telegram.feedback] +title = "Feedback Messages" +description = "Choose when the bot should send feedback to users and channels." + +[admin.settings.telegram.feedback.general.enabled] +label = "Enable Feedback" +description = "Control whether the bot sends feedback messages at all." + +[admin.settings.telegram.feedback.channel] +title = "Channel Feedback Rules" +noValidDocument.label = "Show \"No valid document\" (Channel)" +noValidDocument.description = "Suppress the no valid document response for channel uploads." +errorProcessing.label = "Show processing errors (Channel)" +errorProcessing.description = "Send processing error messages to channels." +errorMessage.label = "Show error messages (Channel)" +errorMessage.description = "Show detailed error messages for channels." + +[admin.settings.telegram.feedback.user] +title = "User Feedback Rules" +noValidDocument.label = "Show \"No valid document\" (User)" +noValidDocument.description = "Suppress the no valid document response for user uploads." +errorProcessing.label = "Show processing errors (User)" +errorProcessing.description = "Send processing error messages to users." +errorMessage.label = "Show error messages (User)" +errorMessage.description = "Show detailed error messages for users." + [admin.settings.connections.mobileScanner] label = "Mobile Phone Upload" enable = "Enable QR Code Upload" diff --git a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx index 15eaadb415..710ae71dbf 100644 --- a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -1,5 +1,18 @@ import { useEffect, useState } from 'react'; -import { Paper, Group, Text, Button, Collapse, Stack, TextInput, Textarea, Switch, PasswordInput } from '@mantine/core'; +import { + Paper, + Group, + Text, + Button, + Collapse, + Stack, + TextInput, + Textarea, + Switch, + PasswordInput, + NumberInput, + TagsInput, +} from '@mantine/core'; import { useTranslation } from 'react-i18next'; import LocalIcon from '@app/components/shared/LocalIcon'; import { Provider, ProviderField } from '@app/components/shared/config/configSections/providerDefinitions'; @@ -105,6 +118,36 @@ export default function ProviderCard({ /> ); + case 'number': + return ( + handleFieldChange(field.key, num)} + disabled={disabled} + allowDecimal={false} + /> + ); + + case 'tags': { + const tagValue = Array.isArray(value) ? value.map((val) => `${val}`) : []; + + return ( + handleFieldChange(field.key, vals)} + disabled={disabled} + /> + ); + } + default: return ( { + const { t } = useTranslation(); + + return { + id: 'telegram', + name: t('admin.settings.telegram.title', 'Telegram Bot'), + icon: 'send-rounded', + type: 'telegram', + scope: t( + 'admin.settings.telegram.description', + 'Configure Telegram bot connectivity, access controls, and feedback behavior.' + ), + fields: [ + { + key: 'enabled', + type: 'switch', + label: t('admin.settings.telegram.enabled.label', 'Enable Telegram Bot'), + description: t( + 'admin.settings.telegram.enabled.description', + 'Allow users to interact with Stirling PDF through your configured Telegram bot.' + ), + defaultValue: false, + }, + { + key: 'botUsername', + type: 'text', + label: t('admin.settings.telegram.botUsername.label', 'Bot Username'), + description: t( + 'admin.settings.telegram.botUsername.description', + 'The public username of your Telegram bot.' + ), + placeholder: 'my_pdf_bot', + }, + { + key: 'botToken', + type: 'password', + label: t('admin.settings.telegram.botToken.label', 'Bot Token'), + description: t( + 'admin.settings.telegram.botToken.description', + 'API token provided by BotFather for your Telegram bot.' + ), + placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11', + }, + { + key: 'pipelineInboxFolder', + type: 'text', + label: t('admin.settings.telegram.pipelineInboxFolder.label', 'Inbox Folder'), + description: t( + 'admin.settings.telegram.pipelineInboxFolder.description', + 'Folder under the pipeline directory where incoming Telegram files are stored.' + ), + placeholder: 'telegram', + }, + { + key: 'customFolderSuffix', + type: 'switch', + label: t('admin.settings.telegram.customFolderSuffix.label', 'Use Custom Folder Suffix'), + description: t( + 'admin.settings.telegram.customFolderSuffix.description', + 'Append the chat ID to incoming file folders to isolate uploads per chat.' + ), + defaultValue: false, + }, + { + key: 'enableAllowUserIDs', + type: 'switch', + label: t('admin.settings.telegram.enableAllowUserIDs.label', 'Allow Specific User IDs'), + description: t( + 'admin.settings.telegram.enableAllowUserIDs.description', + 'When enabled, only listed user IDs can use the bot.' + ), + defaultValue: false, + }, + { + key: 'allowUserIDs', + type: 'tags', + label: t('admin.settings.telegram.allowUserIDs.label', 'Allowed User IDs'), + description: t( + 'admin.settings.telegram.allowUserIDs.description', + 'Enter Telegram user IDs allowed to interact with the bot.' + ), + placeholder: t('admin.settings.telegram.allowUserIDs.placeholder', 'Add user ID and press enter'), + defaultValue: [], + }, + { + key: 'enableAllowChannelIDs', + type: 'switch', + label: t('admin.settings.telegram.enableAllowChannelIDs.label', 'Allow Specific Channel IDs'), + description: t( + 'admin.settings.telegram.enableAllowChannelIDs.description', + 'When enabled, only listed channel IDs can use the bot.' + ), + defaultValue: false, + }, + { + key: 'allowChannelIDs', + type: 'tags', + label: t('admin.settings.telegram.allowChannelIDs.label', 'Allowed Channel IDs'), + description: t( + 'admin.settings.telegram.allowChannelIDs.description', + 'Enter Telegram channel IDs allowed to interact with the bot.' + ), + placeholder: t('admin.settings.telegram.allowChannelIDs.placeholder', 'Add channel ID and press enter'), + defaultValue: [], + }, + { + key: 'processingTimeoutSeconds', + type: 'number', + label: t( + 'admin.settings.telegram.processingTimeoutSeconds.label', + 'Processing Timeout (seconds)' + ), + description: t( + 'admin.settings.telegram.processingTimeoutSeconds.description', + 'Maximum time to wait for a processing job before reporting an error.' + ), + defaultValue: 180, + }, + { + key: 'pollingIntervalMillis', + type: 'number', + label: t('admin.settings.telegram.pollingIntervalMillis.label', 'Polling Interval (ms)'), + description: t( + 'admin.settings.telegram.pollingIntervalMillis.description', + 'Interval between checks for new Telegram updates.' + ), + defaultValue: 2000, + }, + { + key: 'feedback.general.enabled', + type: 'switch', + label: t('admin.settings.telegram.feedback.general.enabled.label', 'Enable Feedback'), + description: t( + 'admin.settings.telegram.feedback.general.enabled.description', + 'Control whether the bot sends feedback messages at all.' + ), + defaultValue: true, + }, + { + key: 'feedback.channel.noValidDocument', + type: 'switch', + label: t( + 'admin.settings.telegram.feedback.channel.noValidDocument.label', + 'Show "No valid document" (Channel)' + ), + description: t( + 'admin.settings.telegram.feedback.channel.noValidDocument.description', + 'Suppress the no valid document response for channel uploads.' + ), + defaultValue: false, + }, + { + key: 'feedback.channel.errorProcessing', + type: 'switch', + label: t( + 'admin.settings.telegram.feedback.channel.errorProcessing.label', + 'Show processing errors (Channel)' + ), + description: t( + 'admin.settings.telegram.feedback.channel.errorProcessing.description', + 'Send processing error messages to channels.' + ), + defaultValue: false, + }, + { + key: 'feedback.channel.errorMessage', + type: 'switch', + label: t( + 'admin.settings.telegram.feedback.channel.errorMessage.label', + 'Show error messages (Channel)' + ), + description: t( + 'admin.settings.telegram.feedback.channel.errorMessage.description', + 'Show detailed error messages for channels.' + ), + defaultValue: false, + }, + { + key: 'feedback.user.noValidDocument', + type: 'switch', + label: t('admin.settings.telegram.feedback.user.noValidDocument.label', 'Show "No valid document" (User)'), + description: t( + 'admin.settings.telegram.feedback.user.noValidDocument.description', + 'Suppress the no valid document response for user uploads.' + ), + defaultValue: false, + }, + { + key: 'feedback.user.errorProcessing', + type: 'switch', + label: t('admin.settings.telegram.feedback.user.errorProcessing.label', 'Show processing errors (User)'), + description: t( + 'admin.settings.telegram.feedback.user.errorProcessing.description', + 'Send processing error messages to users.' + ), + defaultValue: false, + }, + { + key: 'feedback.user.errorMessage', + type: 'switch', + label: t('admin.settings.telegram.feedback.user.errorMessage.label', 'Show error messages (User)'), + description: t( + 'admin.settings.telegram.feedback.user.errorMessage.description', + 'Show detailed error messages for users.' + ), + defaultValue: false, + }, + ], + }; +}; export const SAML2_PROVIDER: Provider = { id: 'saml2', @@ -352,4 +564,14 @@ export const SAML2_PROVIDER: Provider = { ], }; -export const ALL_PROVIDERS = [...OAUTH2_PROVIDERS, GENERIC_OAUTH2_PROVIDER, SAML2_PROVIDER, SMTP_PROVIDER]; +export const useAllProviders = (): Provider[] => { + const telegramProvider = useTelegramProvider(); + + return [ + ...OAUTH2_PROVIDERS, + GENERIC_OAUTH2_PROVIDER, + SAML2_PROVIDER, + SMTP_PROVIDER, + telegramProvider, + ]; +}; diff --git a/frontend/src/core/components/shared/config/types.ts b/frontend/src/core/components/shared/config/types.ts index 740e6130e7..9a73890516 100644 --- a/frontend/src/core/components/shared/config/types.ts +++ b/frontend/src/core/components/shared/config/types.ts @@ -18,6 +18,7 @@ export const VALID_NAV_KEYS = [ 'adminGeneral', 'adminSecurity', 'adminConnections', + 'adminTelegram', 'adminPrivacy', 'adminDatabase', 'adminAdvanced', @@ -33,4 +34,4 @@ export const VALID_NAV_KEYS = [ // Derive the type from the array export type NavKey = typeof VALID_NAV_KEYS[number]; -// some of these are not used yet, but appear in figma designs \ No newline at end of file +// some of these are not used yet, but appear in figma designs diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index 85a29c8f1a..c6c377539e 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -10,14 +10,38 @@ import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex'; import ProviderCard from '@app/components/shared/config/configSections/ProviderCard'; -import { - ALL_PROVIDERS, - Provider, -} from '@app/components/shared/config/configSections/providerDefinitions'; +import { Provider, useAllProviders } from '@app/components/shared/config/configSections/providerDefinitions'; import apiClient from '@app/services/apiClient'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; +interface FeedbackFlags { + noValidDocument?: boolean; + errorProcessing?: boolean; + errorMessage?: boolean; +} + +interface FeedbackSettings { + general?: { enabled?: boolean }; + channel?: FeedbackFlags; + user?: FeedbackFlags; +} + +interface TelegramSettingsData { + enabled?: boolean; + botToken?: string; + botUsername?: string; + pipelineInboxFolder?: string; + customFolderSuffix?: boolean; + enableAllowUserIDs?: boolean; + allowUserIDs?: number[]; + enableAllowChannelIDs?: boolean; + allowChannelIDs?: number[]; + processingTimeoutSeconds?: number; + pollingIntervalMillis?: number; + feedback?: FeedbackSettings; +} + interface ConnectionsSettingsData { oauth2?: { enabled?: boolean; @@ -45,6 +69,7 @@ interface ConnectionsSettingsData { password?: string; from?: string; }; + telegram?: TelegramSettingsData; ssoAutoLogin?: boolean; enableMobileScanner?: boolean; mobileScannerConvertToPdf?: boolean; @@ -58,6 +83,7 @@ export default function AdminConnectionsSection() { const navigate = useNavigate(); const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const allProviders = useAllProviders(); const adminSettings = useAdminSettings({ sectionName: 'connections', @@ -74,6 +100,10 @@ export default function AdminConnectionsSection() { const premiumResponse = await apiClient.get('/api/v1/admin/settings/section/premium'); const premiumData = premiumResponse.data || {}; + // Fetch Telegram settings + const telegramResponse = await apiClient.get('/api/v1/admin/settings/section/telegram'); + const telegramData = telegramResponse.data || {}; + // Fetch system settings for enableMobileScanner const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system'); const systemData = systemResponse.data || {}; @@ -82,6 +112,7 @@ export default function AdminConnectionsSection() { oauth2: securityData.oauth2 || {}, saml2: securityData.saml2 || {}, mail: mailData || {}, + telegram: telegramData || {}, ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false, enableMobileScanner: systemData.enableMobileScanner || false, mobileScannerConvertToPdf: systemData.mobileScannerSettings?.convertToPdf !== false, @@ -101,6 +132,9 @@ export default function AdminConnectionsSection() { if (mailData._pending) { pendingBlock.mail = mailData._pending; } + if (telegramData._pending) { + pendingBlock.telegram = telegramData._pending; + } if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) { pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin; } @@ -162,6 +196,10 @@ export default function AdminConnectionsSection() { return settings?.mail?.enabled === true; } + if (provider.id === 'telegram') { + return settings?.telegram?.enabled === true; + } + if (provider.id === 'oauth2-generic') { return settings?.oauth2?.enabled === true; } @@ -180,6 +218,10 @@ export default function AdminConnectionsSection() { return settings?.mail || {}; } + if (provider.id === 'telegram') { + return settings?.telegram || {}; + } + if (provider.id === 'oauth2-generic') { // Generic OAuth2 settings are at the root oauth2 level return { @@ -210,6 +252,35 @@ export default function AdminConnectionsSection() { // Mail settings use a different endpoint const response = await apiClient.put('/api/v1/admin/settings/section/mail', providerSettings); + if (response.status === 200) { + await fetchSettings(); // Refresh settings + alert({ + alertType: 'success', + title: t('admin.success', 'Success'), + body: t('admin.settings.saveSuccess', 'Settings saved successfully'), + }); + showRestartModal(); + } else { + throw new Error('Failed to save'); + } + } else if (provider.id === 'telegram') { + const parseToNumberArray = (values: any) => + (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter((value) => !Number.isNaN(value)); + + const response = await apiClient.put('/api/v1/admin/settings/section/telegram', { + ...providerSettings, + allowUserIDs: parseToNumberArray(providerSettings.allowUserIDs), + allowChannelIDs: parseToNumberArray(providerSettings.allowChannelIDs), + processingTimeoutSeconds: providerSettings.processingTimeoutSeconds + ? Number(providerSettings.processingTimeoutSeconds) + : undefined, + pollingIntervalMillis: providerSettings.pollingIntervalMillis + ? Number(providerSettings.pollingIntervalMillis) + : undefined, + }); + if (response.status === 200) { await fetchSettings(); // Refresh settings alert({ @@ -271,11 +342,27 @@ export default function AdminConnectionsSection() { return; } - try{ + try { if (provider.id === 'smtp') { // Mail settings use a different endpoint const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false }); + if (response.status === 200) { + await fetchSettings(); + alert({ + alertType: 'success', + title: t('admin.success', 'Success'), + body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'), + }); + showRestartModal(); + } else { + throw new Error('Failed to disconnect'); + } + } else if (provider.id === 'telegram') { + const response = await apiClient.put('/api/v1/admin/settings/section/telegram', { + enabled: false, + }); + if (response.status === 200) { await fetchSettings(); alert({ @@ -425,8 +512,8 @@ export default function AdminConnectionsSection() { } }; - const linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p)); - const availableProviders = ALL_PROVIDERS.filter((p) => !isProviderConfigured(p)); + const linkedProviders = allProviders.filter((p) => isProviderConfigured(p)); + const availableProviders = allProviders.filter((p) => !isProviderConfigured(p)); return (