Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splitting happens on raw markdown, but chunks are HTML-converted after escaping (< → <) and tags (** → ) make the rendered text longer, so a 4000-char chunk can still exceed 4096 and get rejected. Either measure rendered length when chunking

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: chunk boundaries are now chosen by binary-searching the HTML-rendered length (renderedLength, which runs the markdown-to-HTML conversion) rather than the raw markdown length, so escaping/tag expansion is accounted for before a chunk is emitted. Added test splitChunksRespectRenderedHtmlLengthNotRawMarkdownLength covering this.

sendSingleMessage(chatId, messageThreadId, chunk);
}
}

private void sendSingleMessage(long chatId, Integer messageThreadId, String message) {
String formattedHtmlMessage = convertMarkdownToTelegramHtml(message);

SendMessage htmlMessage = SendMessage.builder()
Expand Down Expand Up @@ -124,6 +133,68 @@ public void sendMessage(long chatId, Integer messageThreadId, String message) {
}
}

private List<String> splitMessage(String message, int maxLength) {
if (message == null) return List.of("");
if (renderedLength(message) <= maxLength) return List.of(message);

List<String> 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<String> chunks, StringBuilder current) {
if (!current.isEmpty()) {
chunks.add(current.toString());
current.setLength(0);
}
}

private String convertMarkdownToTelegramHtml(String markdown) {
if (markdown == null || markdown.isBlank()) return "";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<SendMessage> captor = ArgumentCaptor.forClass(SendMessage.class);
verify(telegramClient, atLeastOnce()).execute(captor.capture());

List<SendMessage> 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<SendMessage> captor = ArgumentCaptor.forClass(SendMessage.class);
verify(telegramClient, atLeastOnce()).execute(captor.capture());

List<SendMessage> 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<SendMessage> captor = ArgumentCaptor.forClass(SendMessage.class);
verify(telegramClient, atLeastOnce()).execute(captor.capture());

List<SendMessage> 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
// -----------------------------------------------------------------------
Expand Down