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..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 @@ -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 = 4096; + 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,68 @@ public void sendMessage(long chatId, Integer messageThreadId, String message) { } } + private List splitMessage(String message, int maxLength) { + 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 (renderedLength(line) > maxLength) { + flush(chunks, current); + var splitAt = findSafeSplitIndex(line, maxLength); + chunks.add(line.substring(0, splitAt)); + line = line.substring(splitAt); + } + + String candidate = current.isEmpty() ? line : current + "\n" + line; + if (!current.isEmpty() && renderedLength(candidate) > maxLength) { + flush(chunks, current); + candidate = 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()); + 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..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 @@ -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,97 @@ void sendMessageFallbacksToSendingRawTextWhenFailingToSendHtml() throws Telegram )); } + // ----------------------------------------------------------------------- + // Long message splitting + // ----------------------------------------------------------------------- + + @Test + void splitsResponsesLongerThanTelegramLimitIntoMultipleMessages() throws TelegramApiException { + TelegramChannel channel = channel("allowed_user"); + 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)); + + 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() <= 4096, "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()))); + } + + @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 // -----------------------------------------------------------------------