diff --git a/plugins/telegram/src/main/java/ai/javaclaw/channels/telegram/TelegramChannel.java b/plugins/telegram/src/main/java/ai/javaclaw/channels/telegram/TelegramChannel.java index 018c427f..6ee17055 100644 --- a/plugins/telegram/src/main/java/ai/javaclaw/channels/telegram/TelegramChannel.java +++ b/plugins/telegram/src/main/java/ai/javaclaw/channels/telegram/TelegramChannel.java @@ -21,6 +21,7 @@ import org.commonmark.parser.Parser; import org.commonmark.renderer.html.HtmlRenderer; +import java.util.ArrayList; import java.util.List; import static java.util.Optional.ofNullable; @@ -29,6 +30,8 @@ public class TelegramChannel implements Channel, SpringLongPollingBot, LongPolli private static final Logger LOGGER = LoggerFactory.getLogger(TelegramChannel.class); + private static final int MAX_MESSAGE_LENGTH = 4000; + private static final Parser MARKDOWN_PARSER = Parser.builder().build(); private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder() .escapeHtml(true) @@ -96,6 +99,12 @@ public void sendMessage(String message) { } public void sendMessage(long chatId, Integer messageThreadId, String message) { + for (String chunk : splitMessage(message, MAX_MESSAGE_LENGTH)) { + sendSingleMessage(chatId, messageThreadId, chunk); + } + } + + private void sendSingleMessage(long chatId, Integer messageThreadId, String message) { String formattedHtmlMessage = convertMarkdownToTelegramHtml(message); SendMessage htmlMessage = SendMessage.builder() @@ -124,6 +133,36 @@ public void sendMessage(long chatId, Integer messageThreadId, String message) { } } + private List splitMessage(String message, int maxLength) { + if (message == null || message.length() <= maxLength) return List.of(message == null ? "" : message); + + List chunks = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + + for (String line : message.split("\n", -1)) { + while (line.length() > maxLength) { + flush(chunks, current); + chunks.add(line.substring(0, maxLength)); + line = line.substring(maxLength); + } + if (!current.isEmpty() && current.length() + 1 + line.length() > maxLength) { + flush(chunks, current); + } + if (!current.isEmpty()) current.append('\n'); + current.append(line); + } + flush(chunks, current); + + return chunks; + } + + private void flush(List chunks, StringBuilder current) { + if (!current.isEmpty()) { + chunks.add(current.toString()); + current.setLength(0); + } + } + private String convertMarkdownToTelegramHtml(String markdown) { if (markdown == null || markdown.isBlank()) return ""; diff --git a/plugins/telegram/src/test/java/ai/javaclaw/channels/telegram/TelegramChannelTest.java b/plugins/telegram/src/test/java/ai/javaclaw/channels/telegram/TelegramChannelTest.java index a8d0378b..e1a8efbd 100644 --- a/plugins/telegram/src/test/java/ai/javaclaw/channels/telegram/TelegramChannelTest.java +++ b/plugins/telegram/src/test/java/ai/javaclaw/channels/telegram/TelegramChannelTest.java @@ -4,6 +4,7 @@ import ai.javaclaw.channels.ChannelRegistry; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.telegram.telegrambots.meta.api.methods.ParseMode; @@ -14,11 +15,18 @@ import org.telegram.telegrambots.meta.exceptions.TelegramApiException; import org.telegram.telegrambots.meta.generics.TelegramClient; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -229,6 +237,49 @@ void sendMessageFallbacksToSendingRawTextWhenFailingToSendHtml() throws Telegram )); } + // ----------------------------------------------------------------------- + // Long message splitting + // ----------------------------------------------------------------------- + + @Test + void splitsResponsesLongerThanTelegramLimitIntoMultipleMessages() throws TelegramApiException { + TelegramChannel channel = channel("allowed_user"); + String paragraph = "word ".repeat(900); // ~4500 chars, exceeds the 4000-char chunk limit + String longResponse = paragraph + "\n\n" + paragraph; + when(agent.respondTo(anyString(), anyString())).thenReturn(longResponse); + + channel.consume(updateFrom("allowed_user", "hello", 42L, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SendMessage.class); + verify(telegramClient, atLeastOnce()).execute(captor.capture()); + + List sentMessages = captor.getAllValues(); + assertTrue(sentMessages.size() > 1, "expected the response to be split into multiple messages"); + for (SendMessage msg : sentMessages) { + assertEquals("42", msg.getChatId()); + assertTrue(msg.getText().length() <= 4000, "chunk exceeds Telegram's message size limit"); + } + + String reconstructed = sentMessages.stream() + .map(SendMessage::getText) + .collect(Collectors.joining(" ")) + .replaceAll("\\s+", " ") + .trim(); + String normalizedOriginal = longResponse.replaceAll("\\s+", " ").trim(); + assertEquals(normalizedOriginal, reconstructed); + } + + @Test + void doesNotSplitResponsesWithinTelegramLimit() throws TelegramApiException { + TelegramChannel channel = channel("allowed_user"); + when(agent.respondTo(anyString(), anyString())).thenReturn("a short response"); + + channel.consume(updateFrom("allowed_user", "hello", 42L, null)); + + verify(telegramClient, times(1)).execute(argThat((SendMessage msg) -> + "42".equals(msg.getChatId()))); + } + // ----------------------------------------------------------------------- // helpers // -----------------------------------------------------------------------