From c6629d2696fb2c4e905c73de8ad08620169f3b1c Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 13 Jul 2026 12:41:11 +0200 Subject: [PATCH] Split long Telegram responses into multiple messages Telegram rejects messages over 4096 characters, causing agent replies that exceed the limit to fail to send. Split responses into chunks that respect line boundaries (falling back to a hard split for oversized lines) before sending, so long replies are delivered across multiple messages instead of erroring out. Fixes #62 --- .../channels/telegram/TelegramChannel.java | 39 ++++++++++++++ .../telegram/TelegramChannelTest.java | 51 +++++++++++++++++++ 2 files changed, 90 insertions(+) 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 // -----------------------------------------------------------------------