Skip to content

Commit 24223e6

Browse files
committed
Add Telegram channel integration.
1 parent 46ebc8a commit 24223e6

17 files changed

Lines changed: 1811 additions & 2 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,12 @@ SSE events include `token`, `reasoning`, `message`, `tool_started`, `tool_comple
301301

302302
Husky supports Feishu WebSocket/webhook adapters with multiple app instances under `channels.feishu.instances.*`. Each instance can be bound to a scene through `channel-bindings.*`, allowing different bots/accounts to expose different prompts, tools, memory, and approval policies.
303303

304+
### Telegram Channel
305+
306+
Husky supports a disabled-by-default Telegram long-polling adapter with multiple bot instances under `channels.telegram.instances.*`. Configure `TELEGRAM_ASSISTANT_ENABLED=true`, `TELEGRAM_ASSISTANT_BOT_TOKEN`, and preferably `TELEGRAM_ASSISTANT_BOT_USERNAME` so `channel-bindings.*` can route the bot to the intended scene.
307+
308+
Telegram v1 supports text messages, group mention gating, forum topic threads, typing indicators, inline approval buttons, and clarification buttons/replies. Long polling is single-consumer per bot token, so run only one Husky process per enabled Telegram token.
309+
304310
## Capabilities
305311

306312
- **ReAct graph runtime** — LangGraph4j drives model -> tool -> observation loops with interrupt/resume approvals.
@@ -349,7 +355,7 @@ Most runtime defaults live in `service/src/main/resources/application.yml`.
349355
| LLM | `spring.ai.openai.*`, `agent.auxiliary.*` |
350356
| Agent loop | `agent.graph.max-react-loops`, `agent.llm.*`, `agent.tool.*`, `agent.checkpoint.enabled` |
351357
| Context | `context.threshold-percent`, `context.context-length`, `context.model-context-lengths`, `context.tail-token-budget` |
352-
| Channels | `channel-bindings.*`, `channels.feishu.instances.*`, `tui.ws.*`, `chatbot.enabled` |
358+
| Channels | `channel-bindings.*`, `channels.feishu.instances.*`, `channels.telegram.instances.*`, `tui.ws.*`, `chatbot.enabled` |
353359
| Scenes | `scenes.default-scene`, `scenes.configs.*.toolsets`, `allowed-tools`, `denied-tools`, `approval`, `backend`, `working-dir`, `memory`, `storage` |
354360
| Execution | `execution.backend.docker.*`, `execution.backend.idle-ttl-seconds` |
355361
| Web | `web.backend`, `web.proxy.*`, `BRAVE_SEARCH_API_KEY`, `TAVILY_API_KEY` |

application/src/main/java/io/github/huskyagent/application/channel/binding/ConfigChannelBindingResolver.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ public Optional<ChannelInstanceBinding> resolveConfigured(ChannelIdentity identi
3535
continue;
3636
}
3737
if (binding.channelType() == identity.getChannelType()
38-
&& binding.platformAccountId().equals(identity.getPlatformAccountId())) {
38+
&& normalizePlatformAccountId(identity.getChannelType(), binding.platformAccountId())
39+
.equals(normalizePlatformAccountId(identity.getChannelType(), identity.getPlatformAccountId()))) {
3940
return Optional.of(binding);
4041
}
4142
}
@@ -102,6 +103,14 @@ private Optional<ChannelType> parseChannelType(String value) {
102103
return Optional.empty();
103104
}
104105

106+
private String normalizePlatformAccountId(ChannelType channelType, String value) {
107+
if (channelType != ChannelType.TELEGRAM || value == null) {
108+
return value;
109+
}
110+
String trimmed = value.trim();
111+
return trimmed.startsWith("@") ? trimmed.substring(1) : trimmed;
112+
}
113+
105114
private boolean isBlank(String value) {
106115
return value == null || value.isBlank();
107116
}

application/src/test/java/io/github/huskyagent/application/channel/binding/ConfigChannelBindingResolverTest.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ void resolvesMatchingBindingByChannelTypeAndPlatformAccountId() {
2626
assertEquals("feishu-qa", resolved.sceneId());
2727
}
2828

29+
@Test
30+
void resolvesTelegramBindingWithLeadingAtUsername() {
31+
ChannelBindingProperties properties = new ChannelBindingProperties();
32+
properties.setBindings(Map.of("telegram-assistant", binding("telegram", "@assistant_bot", "assistant", true)));
33+
ConfigChannelBindingResolver resolver = new ConfigChannelBindingResolver(properties);
34+
35+
ChannelInstanceBinding resolved = resolver.resolve(identity(ChannelType.TELEGRAM, "assistant_bot")).orElseThrow();
36+
37+
assertEquals("telegram-assistant", resolved.bindingId());
38+
assertEquals("assistant", resolved.sceneId());
39+
}
2940
@Test
3041
void ignoresDisabledBinding() {
3142
ChannelBindingProperties properties = new ChannelBindingProperties();

service/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@
3838
<version>2.6.1</version>
3939
</dependency>
4040

41+
<!-- Telegram Bot API -->
42+
<dependency>
43+
<groupId>com.github.pengrad</groupId>
44+
<artifactId>java-telegram-bot-api</artifactId>
45+
<version>9.6.0</version>
46+
</dependency>
47+
4148
<!-- Lombok -->
4249
<dependency>
4350
<groupId>org.projectlombok</groupId>
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package io.github.huskyagent.service.channel.telegram;
2+
3+
import com.pengrad.telegrambot.TelegramBot;
4+
import com.pengrad.telegrambot.model.LinkPreviewOptions;
5+
import com.pengrad.telegrambot.model.Message;
6+
import com.pengrad.telegrambot.model.User;
7+
import com.pengrad.telegrambot.model.request.ChatAction;
8+
import com.pengrad.telegrambot.model.request.InlineKeyboardButton;
9+
import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup;
10+
import com.pengrad.telegrambot.model.request.ReplyParameters;
11+
import com.pengrad.telegrambot.request.AnswerCallbackQuery;
12+
import com.pengrad.telegrambot.request.EditMessageText;
13+
import com.pengrad.telegrambot.request.GetMe;
14+
import com.pengrad.telegrambot.request.SendChatAction;
15+
import com.pengrad.telegrambot.request.SendMessage;
16+
import com.pengrad.telegrambot.response.GetMeResponse;
17+
import com.pengrad.telegrambot.response.SendResponse;
18+
import io.github.huskyagent.infra.channel.ApprovalPrompt;
19+
import io.github.huskyagent.infra.channel.ClarifyPrompt;
20+
import io.github.huskyagent.infra.channel.ReplyTarget;
21+
import lombok.Builder;
22+
import lombok.Value;
23+
import lombok.extern.slf4j.Slf4j;
24+
25+
import java.util.ArrayList;
26+
import java.util.List;
27+
28+
@Slf4j
29+
public class TelegramApiClient {
30+
31+
static final int MAX_MESSAGE_CHARS = 3900;
32+
33+
private final TelegramProperties.InstanceProperties properties;
34+
private final TelegramBot bot;
35+
36+
public TelegramApiClient(TelegramProperties.InstanceProperties properties) {
37+
this(properties, isBlank(properties.getToken()) ? null : new TelegramBot(properties.getToken()));
38+
}
39+
40+
TelegramApiClient(TelegramProperties.InstanceProperties properties, TelegramBot bot) {
41+
this.properties = properties;
42+
this.bot = bot;
43+
}
44+
45+
public TelegramBot bot() {
46+
return bot;
47+
}
48+
49+
public String resolveBotUsername() {
50+
if (!isBlank(properties.getBotUsername())) {
51+
return normalizeUsername(properties.getBotUsername());
52+
}
53+
if (bot == null) {
54+
return "";
55+
}
56+
GetMeResponse response = bot.execute(new GetMe());
57+
if (response == null || !response.isOk() || response.user() == null) {
58+
throw new IllegalStateException("Failed to resolve Telegram bot username");
59+
}
60+
User user = response.user();
61+
String username = normalizeUsername(user.username());
62+
properties.setBotUsername(username);
63+
return username;
64+
}
65+
66+
public void sendText(TelegramSendTarget target, String text) {
67+
if (bot == null || target == null || isBlank(target.getChatId()) || isBlank(text)) {
68+
return;
69+
}
70+
Long chatId = parseLong(target.getChatId());
71+
if (chatId == null) {
72+
log.warn("Invalid Telegram chat id: {}", target.getChatId());
73+
return;
74+
}
75+
for (String part : split(text)) {
76+
SendMessage request = new SendMessage(chatId, part).linkPreviewOptions(new LinkPreviewOptions().isDisabled(true));
77+
Integer threadId = parseInteger(target.getThreadId());
78+
if (threadId != null) {
79+
request.messageThreadId(threadId);
80+
}
81+
Integer replyTo = parseInteger(target.getMessageId());
82+
if (replyTo != null) {
83+
request.replyParameters(new ReplyParameters(replyTo));
84+
}
85+
bot.execute(request);
86+
}
87+
}
88+
89+
public void typing(TelegramSendTarget target) {
90+
if (bot == null || target == null || isBlank(target.getChatId())) {
91+
return;
92+
}
93+
Long chatId = parseLong(target.getChatId());
94+
if (chatId == null) {
95+
return;
96+
}
97+
SendChatAction request = new SendChatAction(chatId, ChatAction.typing);
98+
Integer threadId = parseInteger(target.getThreadId());
99+
if (threadId != null) {
100+
request.messageThreadId(threadId);
101+
}
102+
bot.execute(request);
103+
}
104+
105+
public Integer sendApprovalMessage(TelegramSendTarget target, ApprovalPrompt prompt, String approveData, String alwaysData, String rejectData) {
106+
String text = "Approval required\n"
107+
+ "Tool: " + safe(prompt.getToolName()) + "\n"
108+
+ "Reason: " + safe(prompt.getReason()) + "\n"
109+
+ "Arguments: " + truncate(safe(prompt.getToolArgs()), 1200);
110+
InlineKeyboardMarkup keyboard = new InlineKeyboardMarkup(new InlineKeyboardButton[]{
111+
new InlineKeyboardButton("Approve").callbackData(approveData),
112+
new InlineKeyboardButton("Always allow").callbackData(alwaysData),
113+
new InlineKeyboardButton("Reject").callbackData(rejectData)
114+
});
115+
return sendInteractiveMessage(target, text, keyboard);
116+
}
117+
118+
public void editApprovalMessage(String chatId, Integer messageId, ApprovalPrompt prompt, String status) {
119+
String text = "Approval " + status + "\n"
120+
+ "Tool: " + safe(prompt.getToolName()) + "\n"
121+
+ "Reason: " + safe(prompt.getReason());
122+
editMessage(chatId, messageId, text);
123+
}
124+
125+
public Integer sendClarifyMessage(TelegramSendTarget target, ClarifyPrompt prompt, List<String> callbackData) {
126+
String text = "Clarification required\n" + safe(prompt.getQuestion());
127+
InlineKeyboardMarkup keyboard = null;
128+
List<String> options = prompt.getOptions();
129+
if (options != null && !options.isEmpty()) {
130+
InlineKeyboardButton[][] rows = new InlineKeyboardButton[options.size()][];
131+
for (int i = 0; i < options.size(); i++) {
132+
rows[i] = new InlineKeyboardButton[]{new InlineKeyboardButton(options.get(i)).callbackData(callbackData.get(i))};
133+
}
134+
keyboard = new InlineKeyboardMarkup(rows);
135+
}
136+
return sendInteractiveMessage(target, text, keyboard);
137+
}
138+
139+
public void editClarifyMessage(String chatId, Integer messageId, ClarifyPrompt prompt, String status, String answer) {
140+
String text = "Clarification " + status + "\n"
141+
+ safe(prompt.getQuestion())
142+
+ (isBlank(answer) ? "" : "\nAnswer: " + safe(answer));
143+
editMessage(chatId, messageId, text);
144+
}
145+
146+
public void answerCallback(String callbackQueryId, String text) {
147+
if (bot == null || isBlank(callbackQueryId)) {
148+
return;
149+
}
150+
bot.execute(new AnswerCallbackQuery(callbackQueryId).text(text != null ? text : ""));
151+
}
152+
153+
private Integer sendInteractiveMessage(TelegramSendTarget target, String text, InlineKeyboardMarkup keyboard) {
154+
if (bot == null || target == null || isBlank(target.getChatId())) {
155+
return null;
156+
}
157+
Long chatId = parseLong(target.getChatId());
158+
if (chatId == null) {
159+
return null;
160+
}
161+
SendMessage request = new SendMessage(chatId, truncate(text, MAX_MESSAGE_CHARS)).linkPreviewOptions(new LinkPreviewOptions().isDisabled(true));
162+
Integer threadId = parseInteger(target.getThreadId());
163+
if (threadId != null) {
164+
request.messageThreadId(threadId);
165+
}
166+
Integer replyTo = parseInteger(target.getMessageId());
167+
if (replyTo != null) {
168+
request.replyParameters(new ReplyParameters(replyTo));
169+
}
170+
if (keyboard != null) {
171+
request.replyMarkup(keyboard);
172+
}
173+
SendResponse response = bot.execute(request);
174+
Message message = response != null ? response.message() : null;
175+
return message != null ? message.messageId() : null;
176+
}
177+
178+
private void editMessage(String chatIdValue, Integer messageId, String text) {
179+
if (bot == null || isBlank(chatIdValue) || messageId == null) {
180+
return;
181+
}
182+
Long chatId = parseLong(chatIdValue);
183+
if (chatId == null) {
184+
return;
185+
}
186+
bot.execute(new EditMessageText(chatId, messageId, truncate(text, MAX_MESSAGE_CHARS)).linkPreviewOptions(new LinkPreviewOptions().isDisabled(true)));
187+
}
188+
189+
static TelegramSendTarget target(ReplyTarget replyTarget) {
190+
if (replyTarget == null) {
191+
return TelegramSendTarget.builder().build();
192+
}
193+
return TelegramSendTarget.builder()
194+
.chatId(replyTarget.getChatId())
195+
.threadId(replyTarget.getThreadId())
196+
.messageId(replyTarget.getMessageId())
197+
.build();
198+
}
199+
200+
static String normalizeUsername(String username) {
201+
if (username == null) {
202+
return "";
203+
}
204+
String value = username.trim();
205+
return value.startsWith("@") ? value.substring(1) : value;
206+
}
207+
208+
private List<String> split(String text) {
209+
if (text.length() <= MAX_MESSAGE_CHARS) {
210+
return List.of(text);
211+
}
212+
List<String> parts = new ArrayList<>();
213+
int index = 0;
214+
while (index < text.length()) {
215+
int end = Math.min(index + MAX_MESSAGE_CHARS, text.length());
216+
parts.add(text.substring(index, end));
217+
index = end;
218+
}
219+
return parts;
220+
}
221+
222+
private static Long parseLong(String value) {
223+
try {
224+
return isBlank(value) ? null : Long.parseLong(value);
225+
} catch (NumberFormatException e) {
226+
return null;
227+
}
228+
}
229+
230+
private static Integer parseInteger(String value) {
231+
try {
232+
return isBlank(value) ? null : Integer.parseInt(value);
233+
} catch (NumberFormatException e) {
234+
return null;
235+
}
236+
}
237+
238+
private static String truncate(String value, int maxChars) {
239+
if (value == null || value.length() <= maxChars) {
240+
return value;
241+
}
242+
return value.substring(0, maxChars - 3) + "...";
243+
}
244+
245+
private static String safe(String value) {
246+
return value != null ? value : "";
247+
}
248+
249+
private static boolean isBlank(String value) {
250+
return value == null || value.isBlank();
251+
}
252+
253+
@Value
254+
@Builder
255+
public static class TelegramSendTarget {
256+
String chatId;
257+
String threadId;
258+
String messageId;
259+
}
260+
}

0 commit comments

Comments
 (0)