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
5 changes: 4 additions & 1 deletion plugins/telegram/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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("<p>", "").replace("</p>", "\n")
.replaceAll("(?s)<h[1-6]>(.*?)</h[1-6]>", "<b>$1</b>\n")
.replaceAll("(?s)<li>(.*?)</li>", "- $1\n")
.replace("<ul>", "").replace("</ul>", "")
.replace("<ol>", "").replace("</ol>", "")
.replace("<hr />", "\n")
.trim();
}

private boolean isAllowedUser(String userName) {
String normalizedUserName = normalizeUsername(userName);
return normalizedUserName != null && normalizedUserName.equalsIgnoreCase(allowedUsername);
Expand Down Expand Up @@ -136,4 +181,4 @@ public Integer getMessageThreadId() {
return messageThreadId;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");

Expand All @@ -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");

Expand All @@ -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");

Expand All @@ -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");

Expand All @@ -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");

Expand Down Expand Up @@ -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 <strong>bold</strong> text and a <a href=\"https://example.com\">link</a>"
.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
// -----------------------------------------------------------------------
Expand Down
Loading