diff --git a/plugins/telegram/build.gradle b/plugins/telegram/build.gradle index d04bc82..9692a49 100644 --- a/plugins/telegram/build.gradle +++ b/plugins/telegram/build.gradle @@ -8,6 +8,9 @@ dependencies { implementation 'org.telegram:telegrambots-springboot-longpolling-starter:9.4.0' implementation 'org.telegram:telegrambots-client:9.4.0' + implementation 'org.commonmark:commonmark:0.21.0' + implementation 'org.commonmark:commonmark-ext-gfm-strikethrough:0.21.0' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} +} \ No newline at end of file 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 fbc2af3..018c427 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 @@ -4,30 +4,43 @@ import ai.javaclaw.channels.Channel; import ai.javaclaw.channels.ChannelMessageReceivedEvent; import ai.javaclaw.channels.ChannelRegistry; +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.telegram.telegrambots.client.okhttp.OkHttpTelegramClient; import org.telegram.telegrambots.longpolling.interfaces.LongPollingUpdateConsumer; import org.telegram.telegrambots.longpolling.starter.SpringLongPollingBot; import org.telegram.telegrambots.longpolling.util.LongPollingSingleThreadUpdateConsumer; +import org.telegram.telegrambots.meta.api.methods.ParseMode; import org.telegram.telegrambots.meta.api.methods.send.SendMessage; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.message.Message; import org.telegram.telegrambots.meta.exceptions.TelegramApiException; import org.telegram.telegrambots.meta.generics.TelegramClient; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; + +import java.util.List; import static java.util.Optional.ofNullable; public class TelegramChannel implements Channel, SpringLongPollingBot, LongPollingSingleThreadUpdateConsumer { - private static final Logger log = LoggerFactory.getLogger(TelegramChannel.class); + private static final Logger LOGGER = LoggerFactory.getLogger(TelegramChannel.class); + + private static final Parser MARKDOWN_PARSER = Parser.builder().build(); + private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder() + .escapeHtml(true) + .extensions(List.of(StrikethroughExtension.create())) + .build(); + private final String botToken; private final String allowedUsername; private final TelegramClient telegramClient; private final Agent agent; private final ChannelRegistry channelRegistry; private Long chatId; - private Integer messageThreadId; public TelegramChannel(String botToken, String allowedUsername, Agent agent, ChannelRegistry channelRegistry) { this(botToken, allowedUsername, new OkHttpTelegramClient(botToken), agent, channelRegistry); @@ -40,7 +53,7 @@ public TelegramChannel(String botToken, String allowedUsername, Agent agent, Cha this.agent = agent; this.channelRegistry = channelRegistry; channelRegistry.registerChannel(this); - log.info("Started Telegram integration"); + LOGGER.info("Started Telegram integration"); } @Override @@ -60,14 +73,14 @@ public void consume(Update update) { Message requestMessage = update.getMessage(); String userName = requestMessage.getFrom() == null ? null : requestMessage.getFrom().getUserName(); if (!isAllowedUser(userName)) { - log.warn("Ignoring Telegram message from unauthorized username '{}'", userName); + LOGGER.warn("Ignoring Telegram message from unauthorized username '{}'", userName); sendMessage("I'm sorry, I don't accept instructions from you."); return; } String messageText = requestMessage.getText(); this.chatId = requestMessage.getChatId(); - this.messageThreadId = requestMessage.getMessageThreadId(); + Integer messageThreadId = requestMessage.getMessageThreadId(); channelRegistry.publishMessageReceivedEvent(new TelegramChannelMessageReceivedEvent(getName(), messageText, chatId, messageThreadId)); String response = agent.respondTo(getConversationId(chatId, messageThreadId), messageText); sendMessage(chatId, messageThreadId, response); @@ -76,25 +89,57 @@ public void consume(Update update) { @Override public void sendMessage(String message) { if (chatId == null) { - log.error("No known chatId, cannot send message '{}'", message); + LOGGER.error("No known chatId, cannot send message '{}'", message); return; } sendMessage(chatId, null, message); } public void sendMessage(long chatId, Integer messageThreadId, String message) { - SendMessage messageMessage = SendMessage.builder() + String formattedHtmlMessage = convertMarkdownToTelegramHtml(message); + + SendMessage htmlMessage = SendMessage.builder() .chatId(chatId) .messageThreadId(messageThreadId) - .text(message) + .text(formattedHtmlMessage) + .parseMode(ParseMode.HTML) .build(); + try { - telegramClient.execute(messageMessage); + telegramClient.execute(htmlMessage); } catch (TelegramApiException e) { - throw new RuntimeException(e); + LOGGER.warn("Failed to send HTML parsed message, falling back to raw text.", e); + + SendMessage fallbackMessage = SendMessage.builder() + .chatId(chatId) + .messageThreadId(messageThreadId) + .text(message) + .build(); + + try { + telegramClient.execute(fallbackMessage); + } catch (TelegramApiException fallbackEx) { + throw new RuntimeException("Failed to send both HTML and fallback messages", fallbackEx); + } } } + private String convertMarkdownToTelegramHtml(String markdown) { + if (markdown == null || markdown.isBlank()) return ""; + + Node document = MARKDOWN_PARSER.parse(markdown); + String html = HTML_RENDERER.render(document); + + // Minimalist replacement logic to handle unsupported structural tags + return html.replace("

", "").replace("

", "\n") + .replaceAll("(?s)(.*?)", "$1\n") + .replaceAll("(?s)
  • (.*?)
  • ", "- $1\n") + .replace("", "") + .replace("
      ", "").replace("
    ", "") + .replace("
    ", "\n") + .trim(); + } + private boolean isAllowedUser(String userName) { String normalizedUserName = normalizeUsername(userName); return normalizedUserName != null && normalizedUserName.equalsIgnoreCase(allowedUsername); @@ -136,4 +181,4 @@ public Integer getMessageThreadId() { return messageThreadId; } } -} +} \ No newline at end of file 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 adbcc52..a8d0378 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 @@ -6,6 +6,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.telegram.telegrambots.meta.api.methods.ParseMode; import org.telegram.telegrambots.meta.api.methods.send.SendMessage; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.User; @@ -90,7 +91,7 @@ void ignoresMessagesFromUnauthorizedUser() { // ----------------------------------------------------------------------- @Test - void usernameMatchingIsCaseInsensitive() throws TelegramApiException { + void usernameMatchingIsCaseInsensitive() { TelegramChannel channel = channel("Allowed_User"); when(agent.respondTo(anyString(), anyString())).thenReturn("hi"); @@ -100,7 +101,7 @@ void usernameMatchingIsCaseInsensitive() throws TelegramApiException { } @Test - void stripsLeadingAtFromConfiguredUsername() throws TelegramApiException { + void stripsLeadingAtFromConfiguredUsername() { TelegramChannel channel = channel("@Allowed_User"); when(agent.respondTo(anyString(), anyString())).thenReturn("hi"); @@ -110,7 +111,7 @@ void stripsLeadingAtFromConfiguredUsername() throws TelegramApiException { } @Test - void stripsLeadingAtFromIncomingUsername() throws TelegramApiException { + void stripsLeadingAtFromIncomingUsername() { TelegramChannel channel = channel("allowed_user"); when(agent.respondTo(anyString(), anyString())).thenReturn("hi"); @@ -124,7 +125,7 @@ void stripsLeadingAtFromIncomingUsername() throws TelegramApiException { // ----------------------------------------------------------------------- @Test - void usesChannelChatIdAsConversationId() throws TelegramApiException { + void usesChannelChatIdAsConversationId() { TelegramChannel channel = channel("allowed_user"); when(agent.respondTo(anyString(), anyString())).thenReturn("hi"); @@ -134,7 +135,7 @@ void usesChannelChatIdAsConversationId() throws TelegramApiException { } @Test - void includesMessageThreadIdInConversationId() throws TelegramApiException { + void includesMessageThreadIdInConversationId() { TelegramChannel channel = channel("allowed_user"); when(agent.respondTo(anyString(), anyString())).thenReturn("hi"); @@ -190,6 +191,44 @@ void sendMessageDoesNothingWhenNoChatIdKnown() { verifyNoInteractions(telegramClient); } + // ----------------------------------------------------------------------- + // Message formatting (Markdown → HTML) + // ----------------------------------------------------------------------- + + @Test + void sendMessageConvertsMarkdownToTelegramHtml() throws TelegramApiException { + TelegramChannel channel = channel("allowed_user"); + when(agent.respondTo(anyString(), anyString())) + .thenReturn("Here is **bold** text and a [link](https://example.com)"); + + channel.consume(updateFrom("allowed_user", "hello", 42L, 567)); + + verify(telegramClient).execute(argThat((SendMessage msg) -> + ParseMode.HTML.equals(msg.getParseMode()) && + "Here is bold text and a link" + .equals(msg.getText()) + )); + } + + + @Test + void sendMessageFallbacksToSendingRawTextWhenFailingToSendHtml() throws TelegramApiException { + TelegramChannel channel = channel("allowed_user"); + when(agent.respondTo(anyString(), anyString())) + .thenReturn("Here is **bold** text and an image: ![An example image](/assets/images/clawrunr.png)"); + + when(telegramClient.execute(argThat((SendMessage msg) -> + ParseMode.HTML.equals(msg.getParseMode()) + ))).thenThrow(new TelegramApiException("Invalid HTML")); + + channel.consume(updateFrom("allowed_user", "hello", 42L, 567)); + + verify(telegramClient).execute(argThat((SendMessage msg) -> + msg.getParseMode() == null && + msg.getText().equals("Here is **bold** text and an image: ![An example image](/assets/images/clawrunr.png)") + )); + } + // ----------------------------------------------------------------------- // helpers // -----------------------------------------------------------------------