From c6629d2696fb2c4e905c73de8ad08620169f3b1c Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 13 Jul 2026 12:41:11 +0200 Subject: [PATCH 1/2] 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 // ----------------------------------------------------------------------- From 49405ef3300f2aa6c4d9c0adfcd6f95ce07d6a4d Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 22 Jul 2026 12:46:36 +0200 Subject: [PATCH 2/2] Fix surrogate-pair splitting and rendered-length overflow in Telegram chunking Split points are now found via binary search over the HTML-rendered length instead of the raw markdown length, and a cut is backed off by one code unit if it would break a UTF-16 surrogate pair (emoji). Fixes both issues raised in review: truncation could exceed 4096 once markdown was rendered to HTML, and truncation could split an emoji into invalid halves. --- .../channels/telegram/TelegramChannel.java | 48 ++++++++++++++--- .../telegram/TelegramChannelTest.java | 54 +++++++++++++++++-- 2 files changed, 91 insertions(+), 11 deletions(-) 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 6ee17055..69eea31a 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 @@ -30,7 +30,7 @@ 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 int MAX_MESSAGE_LENGTH = 4096; private static final Parser MARKDOWN_PARSER = Parser.builder().build(); private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder() @@ -134,28 +134,60 @@ private void sendSingleMessage(long chatId, Integer messageThreadId, String mess } private List splitMessage(String message, int maxLength) { - if (message == null || message.length() <= maxLength) return List.of(message == null ? "" : message); + if (message == null) return List.of(""); + if (renderedLength(message) <= maxLength) return List.of(message); List chunks = new ArrayList<>(); StringBuilder current = new StringBuilder(); for (String line : message.split("\n", -1)) { - while (line.length() > maxLength) { + while (renderedLength(line) > maxLength) { flush(chunks, current); - chunks.add(line.substring(0, maxLength)); - line = line.substring(maxLength); + var splitAt = findSafeSplitIndex(line, maxLength); + chunks.add(line.substring(0, splitAt)); + line = line.substring(splitAt); } - if (!current.isEmpty() && current.length() + 1 + line.length() > maxLength) { + + String candidate = current.isEmpty() ? line : current + "\n" + line; + if (!current.isEmpty() && renderedLength(candidate) > maxLength) { flush(chunks, current); + candidate = line; } - if (!current.isEmpty()) current.append('\n'); - current.append(line); + current.setLength(0); + current.append(candidate); } flush(chunks, current); return chunks; } + /** + * Finds the largest prefix of {@code line} whose rendered HTML form fits within + * {@code maxLength}, backing off by one code unit if the cut would split a surrogate pair. + */ + private int findSafeSplitIndex(String line, int maxLength) { + var lowestCandidate = 0; + var highestCandidate = Math.min(maxLength, line.length()); + while (lowestCandidate < highestCandidate) { + var candidateIndex = (lowestCandidate + highestCandidate + 1) / 2; + if (renderedLength(line.substring(0, candidateIndex)) <= maxLength) { + lowestCandidate = candidateIndex; + } else { + highestCandidate = candidateIndex - 1; + } + } + // Force at least one character even if none fits, so the caller always makes progress. + var splitIndex = Math.max(lowestCandidate, 1); + if (splitIndex > 1 && Character.isHighSurrogate(line.charAt(splitIndex - 1))) { + splitIndex--; + } + return splitIndex; + } + + private int renderedLength(String text) { + return convertMarkdownToTelegramHtml(text).length(); + } + private void flush(List chunks, StringBuilder current) { if (!current.isEmpty()) { chunks.add(current.toString()); 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 e1a8efbd..65330e63 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 @@ -244,8 +244,9 @@ void sendMessageFallbacksToSendingRawTextWhenFailingToSendHtml() throws Telegram @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; + String line = "word ".repeat(20); + String paragraph = (line + "\n").repeat(45); // ~4500 chars, exceeds the 4096-char Telegram limit + String longResponse = paragraph + "\n" + paragraph; when(agent.respondTo(anyString(), anyString())).thenReturn(longResponse); channel.consume(updateFrom("allowed_user", "hello", 42L, null)); @@ -257,7 +258,7 @@ void splitsResponsesLongerThanTelegramLimitIntoMultipleMessages() throws Telegra 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"); + assertTrue(msg.getText().length() <= 4096, "chunk exceeds Telegram's message size limit"); } String reconstructed = sentMessages.stream() @@ -280,6 +281,53 @@ void doesNotSplitResponsesWithinTelegramLimit() throws TelegramApiException { "42".equals(msg.getChatId()))); } + @Test + void doesNotSplitEmojiSurrogatePairAcrossChunks() throws TelegramApiException { + TelegramChannel channel = channel("allowed_user"); + String longResponse = "a".repeat(4095) + "😀" + "a".repeat(500); + 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) { + assertNoBrokenSurrogates(msg.getText()); + } + + String reconstructed = sentMessages.stream().map(SendMessage::getText).collect(Collectors.joining()); + assertEquals(longResponse, reconstructed); + } + + @Test + void splitChunksRespectRenderedHtmlLengthNotRawMarkdownLength() throws TelegramApiException { + TelegramChannel channel = channel("allowed_user"); + String longResponse = "**bold** ".repeat(600); + 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) { + assertTrue(msg.getText().length() <= 4096, "rendered chunk exceeds Telegram's message size limit"); + } + } + + private void assertNoBrokenSurrogates(String text) { + if (text.isEmpty()) return; + assertTrue(!Character.isHighSurrogate(text.charAt(text.length() - 1)), + "chunk ends with an unpaired high surrogate: " + text); + assertTrue(!Character.isLowSurrogate(text.charAt(0)), + "chunk starts with an unpaired low surrogate: " + text); + } + // ----------------------------------------------------------------------- // helpers // -----------------------------------------------------------------------