opc-agent 4.0.11 → 4.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,33 +1,55 @@
1
1
  import type { Message } from '../core/types';
2
2
  import { BaseChannel } from './index';
3
3
  /**
4
- * Telegram channel — supports both long-polling and webhook modes.
4
+ * Telegram channel — production-quality Telegram bot integration.
5
5
  *
6
- * Config:
7
- * token: bot token (or TELEGRAM_BOT_TOKEN env var)
8
- * mode: 'polling' | 'webhook' (default: 'polling')
9
- * webhookUrl: required for webhook mode
10
- * port: webhook server port (default: 3001)
11
- *
12
- * Polling mode requires no public URL — ideal for dev/local.
13
- * Webhook mode is more efficient for production.
6
+ * Features (aligned with OpenClaw):
7
+ * - Live stream preview (sendMessage + editMessageText)
8
+ * - Ack reaction on message receipt
9
+ * - Typing indicator throughout processing
10
+ * - HTML parse mode with Markdown fallback
11
+ * - Smart text chunking (paragraph-aware)
12
+ * - /start, /help, /status commands
13
+ * - Photo/document caption handling
14
+ * - Callback query (inline button) support
15
+ * - Reply threading
16
+ * - Error recovery with plain-text fallback
17
+ * - Forum topic support
18
+ * - Group mention filtering
14
19
  */
15
20
  export interface TelegramChannelConfig {
16
21
  token?: string;
17
22
  mode?: 'polling' | 'webhook';
18
23
  webhookUrl?: string;
24
+ webhookSecret?: string;
19
25
  port?: number;
26
+ streaming?: boolean | 'off' | 'partial';
27
+ ackReaction?: string;
28
+ linkPreview?: boolean;
29
+ textChunkLimit?: number;
30
+ requireMention?: boolean;
31
+ botUsername?: string;
20
32
  }
21
33
  export declare class TelegramChannel extends BaseChannel {
22
34
  readonly type = "telegram";
23
35
  private token;
24
36
  private mode;
25
37
  private webhookUrl?;
38
+ private webhookSecret?;
26
39
  private port;
40
+ private botUsername;
41
+ private botInfo;
42
+ private streamingEnabled;
43
+ private ackReaction;
44
+ private linkPreview;
45
+ private textChunkLimit;
46
+ private requireMention;
27
47
  private offset;
28
48
  private polling;
29
49
  private server;
50
+ private streamHandler?;
30
51
  constructor(config?: TelegramChannelConfig);
52
+ setStreamHandler(handler: (msg: Message) => AsyncIterable<string>): void;
31
53
  start(): Promise<void>;
32
54
  stop(): Promise<void>;
33
55
  private startPolling;
@@ -36,12 +58,27 @@ export declare class TelegramChannel extends BaseChannel {
36
58
  private startWebhook;
37
59
  private stopWebhook;
38
60
  private processUpdate;
61
+ private handleCommand;
62
+ private handleCallbackQuery;
39
63
  private streamResponse;
40
- private streamHandler?;
41
- setStreamHandler(handler: (msg: Message) => AsyncIterable<string>): void;
42
- sendMarkdown(chatId: number | string, text: string, replyTo?: number): Promise<any>;
64
+ sendFormattedMessage(chatId: number | string, text: string, replyTo?: number, threadId?: number): Promise<any>;
43
65
  sendMessage(chatId: number | string, text: string): Promise<void>;
66
+ private setReaction;
67
+ private sendTyping;
68
+ private isGroupChat;
69
+ private isMentioned;
70
+ private getSessionId;
71
+ private escapeHtml;
72
+ /**
73
+ * Convert basic Markdown to Telegram-safe HTML.
74
+ * Handles: bold, italic, code, code blocks, links.
75
+ */
76
+ private markdownToHtml;
77
+ /**
78
+ * Smart text splitting — prefer paragraph boundaries (blank lines) before hard length split.
79
+ * Aligned with OpenClaw's chunkMode="newline" behavior.
80
+ */
81
+ private smartSplit;
44
82
  private apiCall;
45
- private splitText;
46
83
  }
47
84
  //# sourceMappingURL=telegram.d.ts.map
@@ -40,24 +40,57 @@ class TelegramChannel extends index_1.BaseChannel {
40
40
  token;
41
41
  mode;
42
42
  webhookUrl;
43
+ webhookSecret;
43
44
  port;
45
+ botUsername = '';
46
+ botInfo = null;
47
+ // Config
48
+ streamingEnabled;
49
+ ackReaction;
50
+ linkPreview;
51
+ textChunkLimit;
52
+ requireMention;
44
53
  // Polling state
45
54
  offset = 0;
46
55
  polling = false;
47
56
  // Webhook state
48
57
  server = null;
58
+ // Stream handler — set by runtime when provider supports streaming
59
+ streamHandler;
49
60
  constructor(config = {}) {
50
61
  super();
51
62
  this.token = config.token ?? process.env.TELEGRAM_BOT_TOKEN ?? '';
52
63
  this.mode = config.mode ?? 'polling';
53
64
  this.webhookUrl = config.webhookUrl;
65
+ this.webhookSecret = config.webhookSecret;
54
66
  this.port = config.port ?? 3001;
67
+ // Feature config
68
+ this.streamingEnabled = config.streaming !== false && config.streaming !== 'off';
69
+ this.ackReaction = config.ackReaction ?? '👀';
70
+ this.linkPreview = config.linkPreview ?? true;
71
+ this.textChunkLimit = config.textChunkLimit ?? 4000;
72
+ this.requireMention = config.requireMention ?? false;
73
+ if (config.botUsername)
74
+ this.botUsername = config.botUsername.replace('@', '').toLowerCase();
75
+ }
76
+ setStreamHandler(handler) {
77
+ this.streamHandler = handler;
55
78
  }
56
79
  async start() {
57
80
  if (!this.token) {
58
81
  console.warn('[TelegramChannel] No bot token provided. Set TELEGRAM_BOT_TOKEN or pass token in config.');
59
82
  return;
60
83
  }
84
+ // Fetch bot info for username detection
85
+ try {
86
+ const me = await this.apiCall('getMe');
87
+ if (me?.result) {
88
+ this.botInfo = me.result;
89
+ this.botUsername = (me.result.username ?? '').toLowerCase();
90
+ console.log(`[TelegramChannel] Bot: @${this.botUsername}`);
91
+ }
92
+ }
93
+ catch { }
61
94
  if (this.mode === 'webhook') {
62
95
  await this.startWebhook();
63
96
  }
@@ -75,7 +108,6 @@ class TelegramChannel extends index_1.BaseChannel {
75
108
  }
76
109
  // ─── Polling Mode ────────────────────────────────────────
77
110
  async startPolling() {
78
- // Delete any existing webhook so polling works
79
111
  await this.apiCall('deleteWebhook');
80
112
  console.log(`[TelegramChannel] Started long-polling mode`);
81
113
  this.polling = true;
@@ -86,12 +118,14 @@ class TelegramChannel extends index_1.BaseChannel {
86
118
  try {
87
119
  const updates = await this.getUpdates();
88
120
  for (const update of updates) {
89
- await this.processUpdate(update);
121
+ // Don't await — process concurrently for better responsiveness
122
+ this.processUpdate(update).catch((err) => {
123
+ console.error('[TelegramChannel] Update processing error:', err);
124
+ });
90
125
  }
91
126
  }
92
127
  catch (err) {
93
128
  console.error('[TelegramChannel] Polling error:', err);
94
- // Back off on error
95
129
  if (this.polling) {
96
130
  await new Promise((r) => setTimeout(r, 5000));
97
131
  }
@@ -99,9 +133,9 @@ class TelegramChannel extends index_1.BaseChannel {
99
133
  }
100
134
  }
101
135
  async getUpdates() {
102
- const url = `https://api.telegram.org/bot${this.token}/getUpdates?offset=${this.offset}&timeout=30&allowed_updates=["message"]`;
136
+ const url = `https://api.telegram.org/bot${this.token}/getUpdates?offset=${this.offset}&timeout=30&allowed_updates=${encodeURIComponent('["message","callback_query","message_reaction"]')}`;
103
137
  const controller = new AbortController();
104
- const timeout = setTimeout(() => controller.abort(), 40000); // 30s long-poll + 10s buffer
138
+ const timeout = setTimeout(() => controller.abort(), 40000);
105
139
  try {
106
140
  const res = await fetch(url, { signal: controller.signal });
107
141
  const data = (await res.json());
@@ -117,14 +151,28 @@ class TelegramChannel extends index_1.BaseChannel {
117
151
  // ─── Webhook Mode ────────────────────────────────────────
118
152
  async startWebhook() {
119
153
  if (this.webhookUrl) {
120
- await this.apiCall('setWebhook', { url: `${this.webhookUrl}/webhook/${this.token}` });
154
+ const params = {
155
+ url: `${this.webhookUrl}/webhook/${this.token}`,
156
+ allowed_updates: ['message', 'callback_query', 'message_reaction'],
157
+ };
158
+ if (this.webhookSecret)
159
+ params.secret_token = this.webhookSecret;
160
+ await this.apiCall('setWebhook', params);
121
161
  }
122
162
  const express = (await Promise.resolve().then(() => __importStar(require('express')))).default;
123
163
  const app = express();
124
164
  app.use(express.json());
125
165
  app.post(`/webhook/${this.token}`, async (req, res) => {
166
+ // Verify secret if configured
167
+ if (this.webhookSecret && req.headers['x-telegram-bot-api-secret-token'] !== this.webhookSecret) {
168
+ res.status(403).json({ error: 'Forbidden' });
169
+ return;
170
+ }
126
171
  try {
127
- await this.processUpdate(req.body);
172
+ // Don't await — respond quickly, process in background
173
+ this.processUpdate(req.body).catch((err) => {
174
+ console.error('[TelegramChannel] Webhook processing error:', err);
175
+ });
128
176
  res.json({ ok: true });
129
177
  }
130
178
  catch (err) {
@@ -133,7 +181,7 @@ class TelegramChannel extends index_1.BaseChannel {
133
181
  }
134
182
  });
135
183
  app.get('/health', (_req, res) => {
136
- res.json({ status: 'ok', channel: 'telegram', mode: 'webhook' });
184
+ res.json({ status: 'ok', channel: 'telegram', mode: 'webhook', bot: this.botUsername });
137
185
  });
138
186
  return new Promise((resolve) => {
139
187
  this.server = app.listen(this.port, () => {
@@ -149,159 +197,337 @@ class TelegramChannel extends index_1.BaseChannel {
149
197
  this.server.close((err) => (err ? reject(err) : resolve()));
150
198
  });
151
199
  }
152
- // ─── Shared ──────────────────────────────────────────────
200
+ // ─── Update Processing ──────────────────────────────────
153
201
  async processUpdate(update) {
202
+ // Handle callback queries (inline buttons)
203
+ if (update.callback_query) {
204
+ await this.handleCallbackQuery(update.callback_query);
205
+ return;
206
+ }
154
207
  const message = update.message || update.edited_message;
155
208
  if (!message || !this.handler)
156
209
  return;
157
- // Handle /start command
158
- if (message.text === '/start') {
159
- await this.sendMarkdown(message.chat.id, '👋 Hello! I\'m ready to help. Send me a message to get started.');
160
- return;
210
+ // Handle commands
211
+ if (message.text?.startsWith('/')) {
212
+ const handled = await this.handleCommand(message);
213
+ if (handled)
214
+ return;
161
215
  }
162
- // Handle text, photo captions, document captions
216
+ // Extract text from various message types
163
217
  const text = message.text || message.caption;
164
218
  if (!text)
165
219
  return;
220
+ // Group mention filtering
221
+ if (this.isGroupChat(message) && this.requireMention) {
222
+ if (!this.isMentioned(text))
223
+ return;
224
+ }
225
+ // Build message object
166
226
  const msg = {
167
227
  id: `tg_${message.message_id}`,
168
228
  role: 'user',
169
229
  content: text,
170
230
  timestamp: message.date * 1000,
171
231
  metadata: {
172
- sessionId: `tg_${message.chat.id}`,
232
+ sessionId: this.getSessionId(message),
173
233
  chatId: message.chat.id,
174
234
  userId: message.from?.id,
175
235
  username: message.from?.username,
176
236
  firstName: message.from?.first_name,
237
+ lastName: message.from?.last_name,
177
238
  platform: 'telegram',
178
239
  chatType: message.chat.type,
240
+ messageThreadId: message.message_thread_id,
179
241
  replyToMessageId: message.message_id,
180
242
  },
181
243
  };
182
- // Show typing indicator
183
- await this.apiCall('sendChatAction', { chat_id: message.chat.id, action: 'typing' });
244
+ // Ack reaction — immediate visual feedback
245
+ if (this.ackReaction) {
246
+ this.setReaction(message.chat.id, message.message_id, this.ackReaction).catch(() => { });
247
+ }
248
+ // Typing indicator
249
+ const threadId = message.message_thread_id;
250
+ await this.sendTyping(message.chat.id, threadId);
184
251
  const typingInterval = setInterval(() => {
185
- this.apiCall('sendChatAction', { chat_id: message.chat.id, action: 'typing' }).catch(() => { });
252
+ this.sendTyping(message.chat.id, threadId).catch(() => { });
186
253
  }, 4000);
187
254
  try {
188
- // Try streaming if provider supports it
189
- if (this.streamHandler) {
190
- await this.streamResponse(message.chat.id, msg, message.message_id);
255
+ if (this.streamingEnabled && this.streamHandler) {
256
+ await this.streamResponse(message.chat.id, msg, message.message_id, threadId);
191
257
  }
192
258
  else {
193
259
  const response = await this.handler(msg);
194
- await this.sendMarkdown(message.chat.id, response.content, message.message_id);
260
+ await this.sendFormattedMessage(message.chat.id, response.content, message.message_id, threadId);
261
+ }
262
+ // Remove ack reaction after successful response
263
+ if (this.ackReaction) {
264
+ this.setReaction(message.chat.id, message.message_id, '').catch(() => { });
195
265
  }
196
266
  }
197
267
  catch (err) {
198
268
  console.error('[TelegramChannel] Error processing message:', err);
199
- await this.sendMarkdown(message.chat.id, '⚠️ Sorry, something went wrong. Please try again.');
269
+ await this.sendFormattedMessage(message.chat.id, '⚠️ Sorry, something went wrong. Please try again.', message.message_id, threadId);
200
270
  }
201
271
  finally {
202
272
  clearInterval(typingInterval);
203
273
  }
204
274
  }
205
- async streamResponse(chatId, msg, replyTo) {
275
+ // ─── Commands ───────────────────────────────────────────
276
+ async handleCommand(message) {
277
+ const text = message.text ?? '';
278
+ const command = text.split(' ')[0].split('@')[0].toLowerCase(); // Strip @botname
279
+ switch (command) {
280
+ case '/start':
281
+ await this.sendFormattedMessage(message.chat.id, `👋 <b>Hello${message.from?.first_name ? ' ' + this.escapeHtml(message.from.first_name) : ''}!</b>\n\nI'm ready to help. Send me a message to get started.\n\nCommands:\n/help — Show available commands\n/status — Check bot status`, undefined, message.message_thread_id);
282
+ return true;
283
+ case '/help':
284
+ await this.sendFormattedMessage(message.chat.id, `📖 <b>Available Commands</b>\n\n/start — Start conversation\n/help — Show this help\n/status — Bot status\n\nJust send a message and I'll respond!`, undefined, message.message_thread_id);
285
+ return true;
286
+ case '/status':
287
+ const uptime = process.uptime();
288
+ const hours = Math.floor(uptime / 3600);
289
+ const mins = Math.floor((uptime % 3600) / 60);
290
+ await this.sendFormattedMessage(message.chat.id, `🟢 <b>Bot Status</b>\n\n⏱ Uptime: ${hours}h ${mins}m\n🤖 Bot: @${this.botUsername}\n💬 Mode: ${this.mode}\n📡 Streaming: ${this.streamingEnabled ? 'on' : 'off'}`, undefined, message.message_thread_id);
291
+ return true;
292
+ default:
293
+ return false; // Not a recognized command, let it flow through as normal message
294
+ }
295
+ }
296
+ // ─── Callback Queries (Inline Buttons) ──────────────────
297
+ async handleCallbackQuery(query) {
298
+ // Answer the callback to remove loading state
299
+ await this.apiCall('answerCallbackQuery', { callback_query_id: query.id });
300
+ if (!this.handler || !query.data)
301
+ return;
302
+ const msg = {
303
+ id: `tg_cb_${query.id}`,
304
+ role: 'user',
305
+ content: `callback_data: ${query.data}`,
306
+ timestamp: Date.now(),
307
+ metadata: {
308
+ sessionId: `tg_${query.message?.chat?.id ?? query.from.id}`,
309
+ chatId: query.message?.chat?.id ?? query.from.id,
310
+ userId: query.from.id,
311
+ username: query.from.username,
312
+ firstName: query.from.first_name,
313
+ platform: 'telegram',
314
+ chatType: query.message?.chat?.type ?? 'private',
315
+ isCallback: true,
316
+ },
317
+ };
318
+ try {
319
+ const response = await this.handler(msg);
320
+ const chatId = query.message?.chat?.id ?? query.from.id;
321
+ await this.sendFormattedMessage(chatId, response.content);
322
+ }
323
+ catch (err) {
324
+ console.error('[TelegramChannel] Callback query error:', err);
325
+ }
326
+ }
327
+ // ─── Streaming ──────────────────────────────────────────
328
+ async streamResponse(chatId, msg, replyTo, threadId) {
206
329
  if (!this.streamHandler)
207
330
  return;
208
331
  let sentMessageId = null;
209
332
  let fullText = '';
210
333
  let lastEditTime = 0;
211
- const EDIT_INTERVAL = 1000; // Edit at most once per second
212
- let pendingEdit = false;
213
- const doEdit = async () => {
334
+ const EDIT_INTERVAL = 800; // Edit slightly faster than before
335
+ const MIN_FIRST_SEND = 20; // Min chars before first send (avoid tiny initial message)
336
+ const doEdit = async (final = false) => {
214
337
  if (!sentMessageId || !fullText)
215
338
  return;
216
- pendingEdit = false;
339
+ const displayText = final ? fullText : fullText + ' ▍'; // Cursor indicator while streaming
217
340
  try {
218
341
  await this.apiCall('editMessageText', {
219
342
  chat_id: chatId,
220
343
  message_id: sentMessageId,
221
- text: fullText,
222
- parse_mode: 'Markdown',
344
+ text: displayText,
345
+ parse_mode: 'HTML',
346
+ disable_web_page_preview: !this.linkPreview,
223
347
  });
224
348
  lastEditTime = Date.now();
225
349
  }
226
- catch (err) {
227
- // If Markdown fails, try plain text
228
- if (err?.message?.includes('parse') || err?.message?.includes('entities')) {
229
- try {
230
- await this.apiCall('editMessageText', {
231
- chat_id: chatId,
232
- message_id: sentMessageId,
233
- text: fullText,
234
- });
235
- }
236
- catch { }
350
+ catch {
351
+ // HTML parse failed, try plain text
352
+ try {
353
+ await this.apiCall('editMessageText', {
354
+ chat_id: chatId,
355
+ message_id: sentMessageId,
356
+ text: displayText,
357
+ });
358
+ lastEditTime = Date.now();
237
359
  }
360
+ catch { }
238
361
  }
239
362
  };
240
363
  try {
241
364
  for await (const chunk of this.streamHandler(msg)) {
242
365
  fullText += chunk;
243
- if (!sentMessageId) {
244
- // Send first message
245
- const result = await this.sendMarkdown(chatId, fullText, replyTo);
366
+ if (!sentMessageId && fullText.length >= MIN_FIRST_SEND) {
367
+ const result = await this.sendFormattedMessage(chatId, fullText + ' ▍', replyTo, threadId);
246
368
  sentMessageId = result?.message_id;
247
369
  lastEditTime = Date.now();
248
370
  }
249
- else {
371
+ else if (sentMessageId) {
250
372
  const now = Date.now();
251
373
  if (now - lastEditTime >= EDIT_INTERVAL) {
252
374
  await doEdit();
253
375
  }
254
- else if (!pendingEdit) {
255
- pendingEdit = true;
256
- }
257
376
  }
258
377
  }
259
- // Final edit with complete text
378
+ // Final edit remove cursor, clean formatting
260
379
  if (sentMessageId && fullText) {
261
- await doEdit();
380
+ await doEdit(true);
381
+ }
382
+ else if (!sentMessageId && fullText) {
383
+ // Never sent first message (very short response)
384
+ await this.sendFormattedMessage(chatId, fullText, replyTo, threadId);
262
385
  }
263
386
  }
264
387
  catch (err) {
265
- // If streaming fails, send what we have
266
388
  if (!sentMessageId && fullText) {
267
- await this.sendMarkdown(chatId, fullText, replyTo);
389
+ await this.sendFormattedMessage(chatId, fullText, replyTo, threadId);
268
390
  }
269
391
  throw err;
270
392
  }
271
393
  }
272
- // Stream handler set by runtime when provider supports streaming
273
- streamHandler;
274
- setStreamHandler(handler) {
275
- this.streamHandler = handler;
276
- }
277
- async sendMarkdown(chatId, text, replyTo) {
278
- const chunks = this.splitText(text, 4096);
394
+ // ─── Message Sending ───────────────────────────────────
395
+ async sendFormattedMessage(chatId, text, replyTo, threadId) {
396
+ const chunks = this.smartSplit(text, this.textChunkLimit);
279
397
  let lastResult = null;
280
398
  for (const chunk of chunks) {
399
+ const baseParams = {
400
+ chat_id: chatId,
401
+ disable_web_page_preview: !this.linkPreview,
402
+ ...(replyTo ? { reply_to_message_id: replyTo } : {}),
403
+ ...(threadId ? { message_thread_id: threadId } : {}),
404
+ };
405
+ // Try HTML first (richer formatting)
281
406
  try {
407
+ const htmlText = this.markdownToHtml(chunk);
282
408
  lastResult = await this.apiCall('sendMessage', {
283
- chat_id: chatId,
284
- text: chunk,
285
- parse_mode: 'Markdown',
286
- ...(replyTo ? { reply_to_message_id: replyTo } : {}),
409
+ ...baseParams,
410
+ text: htmlText,
411
+ parse_mode: 'HTML',
287
412
  });
288
- // Only reply to first chunk
289
- replyTo = undefined;
290
413
  }
291
- catch (err) {
292
- // Markdown parse failed send as plain text
293
- lastResult = await this.apiCall('sendMessage', {
294
- chat_id: chatId,
295
- text: chunk,
296
- ...(replyTo ? { reply_to_message_id: replyTo } : {}),
297
- });
298
- replyTo = undefined;
414
+ catch {
415
+ // HTML failed, try Markdown
416
+ try {
417
+ lastResult = await this.apiCall('sendMessage', {
418
+ ...baseParams,
419
+ text: chunk,
420
+ parse_mode: 'Markdown',
421
+ });
422
+ }
423
+ catch {
424
+ // All parsing failed, send plain text
425
+ lastResult = await this.apiCall('sendMessage', {
426
+ ...baseParams,
427
+ text: chunk,
428
+ });
429
+ }
299
430
  }
431
+ // Only reply to first chunk
432
+ replyTo = undefined;
300
433
  }
301
434
  return lastResult?.result;
302
435
  }
303
436
  async sendMessage(chatId, text) {
304
- await this.sendMarkdown(chatId, text);
437
+ await this.sendFormattedMessage(chatId, text);
438
+ }
439
+ // ─── Reactions ──────────────────────────────────────────
440
+ async setReaction(chatId, messageId, emoji) {
441
+ try {
442
+ await this.apiCall('setMessageReaction', {
443
+ chat_id: chatId,
444
+ message_id: messageId,
445
+ reaction: emoji ? [{ type: 'emoji', emoji }] : [],
446
+ });
447
+ }
448
+ catch {
449
+ // Reactions may not be available in all chats
450
+ }
451
+ }
452
+ // ─── Typing ─────────────────────────────────────────────
453
+ async sendTyping(chatId, threadId) {
454
+ await this.apiCall('sendChatAction', {
455
+ chat_id: chatId,
456
+ action: 'typing',
457
+ ...(threadId ? { message_thread_id: threadId } : {}),
458
+ }).catch(() => { });
459
+ }
460
+ // ─── Helpers ────────────────────────────────────────────
461
+ isGroupChat(message) {
462
+ return message.chat.type === 'group' || message.chat.type === 'supergroup';
463
+ }
464
+ isMentioned(text) {
465
+ if (!this.botUsername)
466
+ return true;
467
+ const lower = text.toLowerCase();
468
+ return lower.includes(`@${this.botUsername}`);
469
+ }
470
+ getSessionId(message) {
471
+ const chatId = message.chat.id;
472
+ const threadId = message.message_thread_id;
473
+ if (threadId && message.chat.is_forum) {
474
+ return `tg_${chatId}_topic_${threadId}`;
475
+ }
476
+ return `tg_${chatId}`;
477
+ }
478
+ escapeHtml(text) {
479
+ return text
480
+ .replace(/&/g, '&amp;')
481
+ .replace(/</g, '&lt;')
482
+ .replace(/>/g, '&gt;')
483
+ .replace(/"/g, '&quot;');
484
+ }
485
+ /**
486
+ * Convert basic Markdown to Telegram-safe HTML.
487
+ * Handles: bold, italic, code, code blocks, links.
488
+ */
489
+ markdownToHtml(text) {
490
+ let html = this.escapeHtml(text);
491
+ // Code blocks (```...```)
492
+ html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m, lang, code) => {
493
+ return `<pre${lang ? ` class="language-${lang}"` : ''}>${code}</pre>`;
494
+ });
495
+ // Inline code (`...`)
496
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
497
+ // Bold (**...**)
498
+ html = html.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
499
+ // Italic (*...*)
500
+ html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<i>$1</i>');
501
+ // Links [text](url)
502
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
503
+ return html;
504
+ }
505
+ /**
506
+ * Smart text splitting — prefer paragraph boundaries (blank lines) before hard length split.
507
+ * Aligned with OpenClaw's chunkMode="newline" behavior.
508
+ */
509
+ smartSplit(text, maxLen) {
510
+ if (text.length <= maxLen)
511
+ return [text];
512
+ const parts = [];
513
+ let remaining = text;
514
+ while (remaining.length > maxLen) {
515
+ // Try to find a paragraph break (double newline) near the limit
516
+ let splitAt = remaining.lastIndexOf('\n\n', maxLen);
517
+ if (splitAt < maxLen * 0.3) {
518
+ // No good paragraph break, try single newline
519
+ splitAt = remaining.lastIndexOf('\n', maxLen);
520
+ }
521
+ if (splitAt < maxLen * 0.3) {
522
+ // No good newline, hard split at limit
523
+ splitAt = maxLen;
524
+ }
525
+ parts.push(remaining.slice(0, splitAt).trimEnd());
526
+ remaining = remaining.slice(splitAt).trimStart();
527
+ }
528
+ if (remaining)
529
+ parts.push(remaining);
530
+ return parts;
305
531
  }
306
532
  async apiCall(method, body) {
307
533
  const url = `https://api.telegram.org/bot${this.token}/${method}`;
@@ -311,22 +537,21 @@ class TelegramChannel extends index_1.BaseChannel {
311
537
  headers: { 'Content-Type': 'application/json' },
312
538
  body: body ? JSON.stringify(body) : undefined,
313
539
  });
314
- return await res.json();
540
+ const data = await res.json();
541
+ if (!data.ok) {
542
+ const err = new Error(`Telegram API ${method} failed: ${data.description}`);
543
+ err.telegramError = data;
544
+ throw err;
545
+ }
546
+ return data;
315
547
  }
316
548
  catch (err) {
549
+ if (err.telegramError)
550
+ throw err;
317
551
  console.error(`[TelegramChannel] API call ${method} failed:`, err);
318
552
  throw err;
319
553
  }
320
554
  }
321
- splitText(text, maxLen) {
322
- if (text.length <= maxLen)
323
- return [text];
324
- const parts = [];
325
- for (let i = 0; i < text.length; i += maxLen) {
326
- parts.push(text.slice(i, i + maxLen));
327
- }
328
- return parts;
329
- }
330
555
  }
331
556
  exports.TelegramChannel = TelegramChannel;
332
557
  //# sourceMappingURL=telegram.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opc-agent",
3
- "version": "4.0.11",
3
+ "version": "4.0.12",
4
4
  "description": "Open Agent Framework — Build, test, and run AI Agents for business workstations",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,25 +1,37 @@
1
1
  import type { Message } from '../core/types';
2
- import type { LLMProvider } from '../providers/index';
3
2
  import { BaseChannel } from './index';
4
3
 
5
4
  /**
6
- * Telegram channel — supports both long-polling and webhook modes.
5
+ * Telegram channel — production-quality Telegram bot integration.
7
6
  *
8
- * Config:
9
- * token: bot token (or TELEGRAM_BOT_TOKEN env var)
10
- * mode: 'polling' | 'webhook' (default: 'polling')
11
- * webhookUrl: required for webhook mode
12
- * port: webhook server port (default: 3001)
13
- *
14
- * Polling mode requires no public URL — ideal for dev/local.
15
- * Webhook mode is more efficient for production.
7
+ * Features (aligned with OpenClaw):
8
+ * - Live stream preview (sendMessage + editMessageText)
9
+ * - Ack reaction on message receipt
10
+ * - Typing indicator throughout processing
11
+ * - HTML parse mode with Markdown fallback
12
+ * - Smart text chunking (paragraph-aware)
13
+ * - /start, /help, /status commands
14
+ * - Photo/document caption handling
15
+ * - Callback query (inline button) support
16
+ * - Reply threading
17
+ * - Error recovery with plain-text fallback
18
+ * - Forum topic support
19
+ * - Group mention filtering
16
20
  */
17
21
 
18
22
  export interface TelegramChannelConfig {
19
23
  token?: string;
20
24
  mode?: 'polling' | 'webhook';
21
25
  webhookUrl?: string;
26
+ webhookSecret?: string;
22
27
  port?: number;
28
+ // Feature flags
29
+ streaming?: boolean | 'off' | 'partial';
30
+ ackReaction?: string; // emoji to react with on receipt, e.g. "👀"
31
+ linkPreview?: boolean;
32
+ textChunkLimit?: number;
33
+ requireMention?: boolean; // for groups
34
+ botUsername?: string; // for mention detection
23
35
  }
24
36
 
25
37
  export class TelegramChannel extends BaseChannel {
@@ -27,7 +39,17 @@ export class TelegramChannel extends BaseChannel {
27
39
  private token: string;
28
40
  private mode: 'polling' | 'webhook';
29
41
  private webhookUrl?: string;
42
+ private webhookSecret?: string;
30
43
  private port: number;
44
+ private botUsername: string = '';
45
+ private botInfo: any = null;
46
+
47
+ // Config
48
+ private streamingEnabled: boolean;
49
+ private ackReaction: string;
50
+ private linkPreview: boolean;
51
+ private textChunkLimit: number;
52
+ private requireMention: boolean;
31
53
 
32
54
  // Polling state
33
55
  private offset: number = 0;
@@ -36,12 +58,28 @@ export class TelegramChannel extends BaseChannel {
36
58
  // Webhook state
37
59
  private server: import('http').Server | null = null;
38
60
 
61
+ // Stream handler — set by runtime when provider supports streaming
62
+ private streamHandler?: (msg: Message) => AsyncIterable<string>;
63
+
39
64
  constructor(config: TelegramChannelConfig = {}) {
40
65
  super();
41
66
  this.token = config.token ?? process.env.TELEGRAM_BOT_TOKEN ?? '';
42
67
  this.mode = config.mode ?? 'polling';
43
68
  this.webhookUrl = config.webhookUrl;
69
+ this.webhookSecret = config.webhookSecret;
44
70
  this.port = config.port ?? 3001;
71
+
72
+ // Feature config
73
+ this.streamingEnabled = config.streaming !== false && config.streaming !== 'off';
74
+ this.ackReaction = config.ackReaction ?? '👀';
75
+ this.linkPreview = config.linkPreview ?? true;
76
+ this.textChunkLimit = config.textChunkLimit ?? 4000;
77
+ this.requireMention = config.requireMention ?? false;
78
+ if (config.botUsername) this.botUsername = config.botUsername.replace('@', '').toLowerCase();
79
+ }
80
+
81
+ setStreamHandler(handler: (msg: Message) => AsyncIterable<string>): void {
82
+ this.streamHandler = handler;
45
83
  }
46
84
 
47
85
  async start(): Promise<void> {
@@ -50,6 +88,16 @@ export class TelegramChannel extends BaseChannel {
50
88
  return;
51
89
  }
52
90
 
91
+ // Fetch bot info for username detection
92
+ try {
93
+ const me = await this.apiCall('getMe');
94
+ if (me?.result) {
95
+ this.botInfo = me.result;
96
+ this.botUsername = (me.result.username ?? '').toLowerCase();
97
+ console.log(`[TelegramChannel] Bot: @${this.botUsername}`);
98
+ }
99
+ } catch {}
100
+
53
101
  if (this.mode === 'webhook') {
54
102
  await this.startWebhook();
55
103
  } else {
@@ -68,7 +116,6 @@ export class TelegramChannel extends BaseChannel {
68
116
  // ─── Polling Mode ────────────────────────────────────────
69
117
 
70
118
  private async startPolling(): Promise<void> {
71
- // Delete any existing webhook so polling works
72
119
  await this.apiCall('deleteWebhook');
73
120
  console.log(`[TelegramChannel] Started long-polling mode`);
74
121
  this.polling = true;
@@ -80,11 +127,13 @@ export class TelegramChannel extends BaseChannel {
80
127
  try {
81
128
  const updates = await this.getUpdates();
82
129
  for (const update of updates) {
83
- await this.processUpdate(update);
130
+ // Don't await — process concurrently for better responsiveness
131
+ this.processUpdate(update).catch((err) => {
132
+ console.error('[TelegramChannel] Update processing error:', err);
133
+ });
84
134
  }
85
135
  } catch (err) {
86
136
  console.error('[TelegramChannel] Polling error:', err);
87
- // Back off on error
88
137
  if (this.polling) {
89
138
  await new Promise((r) => setTimeout(r, 5000));
90
139
  }
@@ -93,9 +142,9 @@ export class TelegramChannel extends BaseChannel {
93
142
  }
94
143
 
95
144
  private async getUpdates(): Promise<any[]> {
96
- const url = `https://api.telegram.org/bot${this.token}/getUpdates?offset=${this.offset}&timeout=30&allowed_updates=["message"]`;
145
+ const url = `https://api.telegram.org/bot${this.token}/getUpdates?offset=${this.offset}&timeout=30&allowed_updates=${encodeURIComponent('["message","callback_query","message_reaction"]')}`;
97
146
  const controller = new AbortController();
98
- const timeout = setTimeout(() => controller.abort(), 40000); // 30s long-poll + 10s buffer
147
+ const timeout = setTimeout(() => controller.abort(), 40000);
99
148
 
100
149
  try {
101
150
  const res = await fetch(url, { signal: controller.signal });
@@ -113,7 +162,12 @@ export class TelegramChannel extends BaseChannel {
113
162
 
114
163
  private async startWebhook(): Promise<void> {
115
164
  if (this.webhookUrl) {
116
- await this.apiCall('setWebhook', { url: `${this.webhookUrl}/webhook/${this.token}` });
165
+ const params: Record<string, unknown> = {
166
+ url: `${this.webhookUrl}/webhook/${this.token}`,
167
+ allowed_updates: ['message', 'callback_query', 'message_reaction'],
168
+ };
169
+ if (this.webhookSecret) params.secret_token = this.webhookSecret;
170
+ await this.apiCall('setWebhook', params);
117
171
  }
118
172
 
119
173
  const express = (await import('express')).default;
@@ -121,8 +175,16 @@ export class TelegramChannel extends BaseChannel {
121
175
  app.use(express.json());
122
176
 
123
177
  app.post(`/webhook/${this.token}`, async (req, res) => {
178
+ // Verify secret if configured
179
+ if (this.webhookSecret && req.headers['x-telegram-bot-api-secret-token'] !== this.webhookSecret) {
180
+ res.status(403).json({ error: 'Forbidden' });
181
+ return;
182
+ }
124
183
  try {
125
- await this.processUpdate(req.body);
184
+ // Don't await — respond quickly, process in background
185
+ this.processUpdate(req.body).catch((err) => {
186
+ console.error('[TelegramChannel] Webhook processing error:', err);
187
+ });
126
188
  res.json({ ok: true });
127
189
  } catch (err) {
128
190
  console.error('[TelegramChannel] Webhook error:', err);
@@ -131,7 +193,7 @@ export class TelegramChannel extends BaseChannel {
131
193
  });
132
194
 
133
195
  app.get('/health', (_req, res) => {
134
- res.json({ status: 'ok', channel: 'telegram', mode: 'webhook' });
196
+ res.json({ status: 'ok', channel: 'telegram', mode: 'webhook', bot: this.botUsername });
135
197
  });
136
198
 
137
199
  return new Promise((resolve) => {
@@ -149,92 +211,194 @@ export class TelegramChannel extends BaseChannel {
149
211
  });
150
212
  }
151
213
 
152
- // ─── Shared ──────────────────────────────────────────────
214
+ // ─── Update Processing ──────────────────────────────────
153
215
 
154
216
  private async processUpdate(update: any): Promise<void> {
217
+ // Handle callback queries (inline buttons)
218
+ if (update.callback_query) {
219
+ await this.handleCallbackQuery(update.callback_query);
220
+ return;
221
+ }
222
+
155
223
  const message = update.message || update.edited_message;
156
224
  if (!message || !this.handler) return;
157
225
 
158
- // Handle /start command
159
- if (message.text === '/start') {
160
- await this.sendMarkdown(message.chat.id, '👋 Hello! I\'m ready to help. Send me a message to get started.');
161
- return;
226
+ // Handle commands
227
+ if (message.text?.startsWith('/')) {
228
+ const handled = await this.handleCommand(message);
229
+ if (handled) return;
162
230
  }
163
231
 
164
- // Handle text, photo captions, document captions
232
+ // Extract text from various message types
165
233
  const text = message.text || message.caption;
166
234
  if (!text) return;
167
235
 
236
+ // Group mention filtering
237
+ if (this.isGroupChat(message) && this.requireMention) {
238
+ if (!this.isMentioned(text)) return;
239
+ }
240
+
241
+ // Build message object
168
242
  const msg: Message = {
169
243
  id: `tg_${message.message_id}`,
170
244
  role: 'user',
171
245
  content: text,
172
246
  timestamp: message.date * 1000,
173
247
  metadata: {
174
- sessionId: `tg_${message.chat.id}`,
248
+ sessionId: this.getSessionId(message),
175
249
  chatId: message.chat.id,
176
250
  userId: message.from?.id,
177
251
  username: message.from?.username,
178
252
  firstName: message.from?.first_name,
253
+ lastName: message.from?.last_name,
179
254
  platform: 'telegram',
180
255
  chatType: message.chat.type,
256
+ messageThreadId: message.message_thread_id,
181
257
  replyToMessageId: message.message_id,
182
258
  },
183
259
  };
184
260
 
185
- // Show typing indicator
186
- await this.apiCall('sendChatAction', { chat_id: message.chat.id, action: 'typing' });
261
+ // Ack reaction — immediate visual feedback
262
+ if (this.ackReaction) {
263
+ this.setReaction(message.chat.id, message.message_id, this.ackReaction).catch(() => {});
264
+ }
265
+
266
+ // Typing indicator
267
+ const threadId = message.message_thread_id;
268
+ await this.sendTyping(message.chat.id, threadId);
187
269
  const typingInterval = setInterval(() => {
188
- this.apiCall('sendChatAction', { chat_id: message.chat.id, action: 'typing' }).catch(() => {});
270
+ this.sendTyping(message.chat.id, threadId).catch(() => {});
189
271
  }, 4000);
190
272
 
191
273
  try {
192
- // Try streaming if provider supports it
193
- if (this.streamHandler) {
194
- await this.streamResponse(message.chat.id, msg, message.message_id);
274
+ if (this.streamingEnabled && this.streamHandler) {
275
+ await this.streamResponse(message.chat.id, msg, message.message_id, threadId);
195
276
  } else {
196
277
  const response = await this.handler(msg);
197
- await this.sendMarkdown(message.chat.id, response.content, message.message_id);
278
+ await this.sendFormattedMessage(message.chat.id, response.content, message.message_id, threadId);
279
+ }
280
+
281
+ // Remove ack reaction after successful response
282
+ if (this.ackReaction) {
283
+ this.setReaction(message.chat.id, message.message_id, '').catch(() => {});
198
284
  }
199
285
  } catch (err) {
200
286
  console.error('[TelegramChannel] Error processing message:', err);
201
- await this.sendMarkdown(message.chat.id, '⚠️ Sorry, something went wrong. Please try again.');
287
+ await this.sendFormattedMessage(message.chat.id, '⚠️ Sorry, something went wrong. Please try again.', message.message_id, threadId);
202
288
  } finally {
203
289
  clearInterval(typingInterval);
204
290
  }
205
291
  }
206
292
 
207
- private async streamResponse(chatId: number | string, msg: Message, replyTo?: number): Promise<void> {
293
+ // ─── Commands ───────────────────────────────────────────
294
+
295
+ private async handleCommand(message: any): Promise<boolean> {
296
+ const text = message.text ?? '';
297
+ const command = text.split(' ')[0].split('@')[0].toLowerCase(); // Strip @botname
298
+
299
+ switch (command) {
300
+ case '/start':
301
+ await this.sendFormattedMessage(
302
+ message.chat.id,
303
+ `👋 <b>Hello${message.from?.first_name ? ' ' + this.escapeHtml(message.from.first_name) : ''}!</b>\n\nI'm ready to help. Send me a message to get started.\n\nCommands:\n/help — Show available commands\n/status — Check bot status`,
304
+ undefined,
305
+ message.message_thread_id
306
+ );
307
+ return true;
308
+
309
+ case '/help':
310
+ await this.sendFormattedMessage(
311
+ message.chat.id,
312
+ `📖 <b>Available Commands</b>\n\n/start — Start conversation\n/help — Show this help\n/status — Bot status\n\nJust send a message and I'll respond!`,
313
+ undefined,
314
+ message.message_thread_id
315
+ );
316
+ return true;
317
+
318
+ case '/status':
319
+ const uptime = process.uptime();
320
+ const hours = Math.floor(uptime / 3600);
321
+ const mins = Math.floor((uptime % 3600) / 60);
322
+ await this.sendFormattedMessage(
323
+ message.chat.id,
324
+ `🟢 <b>Bot Status</b>\n\n⏱ Uptime: ${hours}h ${mins}m\n🤖 Bot: @${this.botUsername}\n💬 Mode: ${this.mode}\n📡 Streaming: ${this.streamingEnabled ? 'on' : 'off'}`,
325
+ undefined,
326
+ message.message_thread_id
327
+ );
328
+ return true;
329
+
330
+ default:
331
+ return false; // Not a recognized command, let it flow through as normal message
332
+ }
333
+ }
334
+
335
+ // ─── Callback Queries (Inline Buttons) ──────────────────
336
+
337
+ private async handleCallbackQuery(query: any): Promise<void> {
338
+ // Answer the callback to remove loading state
339
+ await this.apiCall('answerCallbackQuery', { callback_query_id: query.id });
340
+
341
+ if (!this.handler || !query.data) return;
342
+
343
+ const msg: Message = {
344
+ id: `tg_cb_${query.id}`,
345
+ role: 'user',
346
+ content: `callback_data: ${query.data}`,
347
+ timestamp: Date.now(),
348
+ metadata: {
349
+ sessionId: `tg_${query.message?.chat?.id ?? query.from.id}`,
350
+ chatId: query.message?.chat?.id ?? query.from.id,
351
+ userId: query.from.id,
352
+ username: query.from.username,
353
+ firstName: query.from.first_name,
354
+ platform: 'telegram',
355
+ chatType: query.message?.chat?.type ?? 'private',
356
+ isCallback: true,
357
+ },
358
+ };
359
+
360
+ try {
361
+ const response = await this.handler(msg);
362
+ const chatId = query.message?.chat?.id ?? query.from.id;
363
+ await this.sendFormattedMessage(chatId, response.content);
364
+ } catch (err) {
365
+ console.error('[TelegramChannel] Callback query error:', err);
366
+ }
367
+ }
368
+
369
+ // ─── Streaming ──────────────────────────────────────────
370
+
371
+ private async streamResponse(chatId: number | string, msg: Message, replyTo?: number, threadId?: number): Promise<void> {
208
372
  if (!this.streamHandler) return;
209
373
 
210
374
  let sentMessageId: number | null = null;
211
375
  let fullText = '';
212
376
  let lastEditTime = 0;
213
- const EDIT_INTERVAL = 1000; // Edit at most once per second
214
- let pendingEdit = false;
377
+ const EDIT_INTERVAL = 800; // Edit slightly faster than before
378
+ const MIN_FIRST_SEND = 20; // Min chars before first send (avoid tiny initial message)
215
379
 
216
- const doEdit = async () => {
380
+ const doEdit = async (final: boolean = false) => {
217
381
  if (!sentMessageId || !fullText) return;
218
- pendingEdit = false;
382
+ const displayText = final ? fullText : fullText + ' ▍'; // Cursor indicator while streaming
219
383
  try {
220
384
  await this.apiCall('editMessageText', {
221
385
  chat_id: chatId,
222
386
  message_id: sentMessageId,
223
- text: fullText,
224
- parse_mode: 'Markdown',
387
+ text: displayText,
388
+ parse_mode: 'HTML',
389
+ disable_web_page_preview: !this.linkPreview,
225
390
  });
226
391
  lastEditTime = Date.now();
227
- } catch (err: any) {
228
- // If Markdown fails, try plain text
229
- if (err?.message?.includes('parse') || err?.message?.includes('entities')) {
230
- try {
231
- await this.apiCall('editMessageText', {
232
- chat_id: chatId,
233
- message_id: sentMessageId,
234
- text: fullText,
235
- });
236
- } catch {}
237
- }
392
+ } catch {
393
+ // HTML parse failed, try plain text
394
+ try {
395
+ await this.apiCall('editMessageText', {
396
+ chat_id: chatId,
397
+ message_id: sentMessageId,
398
+ text: displayText,
399
+ });
400
+ lastEditTime = Date.now();
401
+ } catch {}
238
402
  }
239
403
  };
240
404
 
@@ -242,69 +406,190 @@ export class TelegramChannel extends BaseChannel {
242
406
  for await (const chunk of this.streamHandler(msg)) {
243
407
  fullText += chunk;
244
408
 
245
- if (!sentMessageId) {
246
- // Send first message
247
- const result = await this.sendMarkdown(chatId, fullText, replyTo);
409
+ if (!sentMessageId && fullText.length >= MIN_FIRST_SEND) {
410
+ const result = await this.sendFormattedMessage(chatId, fullText + ' ▍', replyTo, threadId);
248
411
  sentMessageId = result?.message_id;
249
412
  lastEditTime = Date.now();
250
- } else {
413
+ } else if (sentMessageId) {
251
414
  const now = Date.now();
252
415
  if (now - lastEditTime >= EDIT_INTERVAL) {
253
416
  await doEdit();
254
- } else if (!pendingEdit) {
255
- pendingEdit = true;
256
417
  }
257
418
  }
258
419
  }
259
420
 
260
- // Final edit with complete text
421
+ // Final edit remove cursor, clean formatting
261
422
  if (sentMessageId && fullText) {
262
- await doEdit();
423
+ await doEdit(true);
424
+ } else if (!sentMessageId && fullText) {
425
+ // Never sent first message (very short response)
426
+ await this.sendFormattedMessage(chatId, fullText, replyTo, threadId);
263
427
  }
264
428
  } catch (err) {
265
- // If streaming fails, send what we have
266
429
  if (!sentMessageId && fullText) {
267
- await this.sendMarkdown(chatId, fullText, replyTo);
430
+ await this.sendFormattedMessage(chatId, fullText, replyTo, threadId);
268
431
  }
269
432
  throw err;
270
433
  }
271
434
  }
272
435
 
273
- // Stream handler set by runtime when provider supports streaming
274
- private streamHandler?: (msg: Message) => AsyncIterable<string>;
275
-
276
- setStreamHandler(handler: (msg: Message) => AsyncIterable<string>): void {
277
- this.streamHandler = handler;
278
- }
436
+ // ─── Message Sending ───────────────────────────────────
279
437
 
280
- async sendMarkdown(chatId: number | string, text: string, replyTo?: number): Promise<any> {
281
- const chunks = this.splitText(text, 4096);
438
+ async sendFormattedMessage(chatId: number | string, text: string, replyTo?: number, threadId?: number): Promise<any> {
439
+ const chunks = this.smartSplit(text, this.textChunkLimit);
282
440
  let lastResult: any = null;
441
+
283
442
  for (const chunk of chunks) {
443
+ const baseParams: Record<string, unknown> = {
444
+ chat_id: chatId,
445
+ disable_web_page_preview: !this.linkPreview,
446
+ ...(replyTo ? { reply_to_message_id: replyTo } : {}),
447
+ ...(threadId ? { message_thread_id: threadId } : {}),
448
+ };
449
+
450
+ // Try HTML first (richer formatting)
284
451
  try {
452
+ const htmlText = this.markdownToHtml(chunk);
285
453
  lastResult = await this.apiCall('sendMessage', {
286
- chat_id: chatId,
287
- text: chunk,
288
- parse_mode: 'Markdown',
289
- ...(replyTo ? { reply_to_message_id: replyTo } : {}),
290
- });
291
- // Only reply to first chunk
292
- replyTo = undefined;
293
- } catch (err: any) {
294
- // Markdown parse failed — send as plain text
295
- lastResult = await this.apiCall('sendMessage', {
296
- chat_id: chatId,
297
- text: chunk,
298
- ...(replyTo ? { reply_to_message_id: replyTo } : {}),
454
+ ...baseParams,
455
+ text: htmlText,
456
+ parse_mode: 'HTML',
299
457
  });
300
- replyTo = undefined;
458
+ } catch {
459
+ // HTML failed, try Markdown
460
+ try {
461
+ lastResult = await this.apiCall('sendMessage', {
462
+ ...baseParams,
463
+ text: chunk,
464
+ parse_mode: 'Markdown',
465
+ });
466
+ } catch {
467
+ // All parsing failed, send plain text
468
+ lastResult = await this.apiCall('sendMessage', {
469
+ ...baseParams,
470
+ text: chunk,
471
+ });
472
+ }
301
473
  }
474
+
475
+ // Only reply to first chunk
476
+ replyTo = undefined;
302
477
  }
303
478
  return lastResult?.result;
304
479
  }
305
480
 
306
481
  async sendMessage(chatId: number | string, text: string): Promise<void> {
307
- await this.sendMarkdown(chatId, text);
482
+ await this.sendFormattedMessage(chatId, text);
483
+ }
484
+
485
+ // ─── Reactions ──────────────────────────────────────────
486
+
487
+ private async setReaction(chatId: number | string, messageId: number, emoji: string): Promise<void> {
488
+ try {
489
+ await this.apiCall('setMessageReaction', {
490
+ chat_id: chatId,
491
+ message_id: messageId,
492
+ reaction: emoji ? [{ type: 'emoji', emoji }] : [],
493
+ });
494
+ } catch {
495
+ // Reactions may not be available in all chats
496
+ }
497
+ }
498
+
499
+ // ─── Typing ─────────────────────────────────────────────
500
+
501
+ private async sendTyping(chatId: number | string, threadId?: number): Promise<void> {
502
+ await this.apiCall('sendChatAction', {
503
+ chat_id: chatId,
504
+ action: 'typing',
505
+ ...(threadId ? { message_thread_id: threadId } : {}),
506
+ }).catch(() => {});
507
+ }
508
+
509
+ // ─── Helpers ────────────────────────────────────────────
510
+
511
+ private isGroupChat(message: any): boolean {
512
+ return message.chat.type === 'group' || message.chat.type === 'supergroup';
513
+ }
514
+
515
+ private isMentioned(text: string): boolean {
516
+ if (!this.botUsername) return true;
517
+ const lower = text.toLowerCase();
518
+ return lower.includes(`@${this.botUsername}`);
519
+ }
520
+
521
+ private getSessionId(message: any): string {
522
+ const chatId = message.chat.id;
523
+ const threadId = message.message_thread_id;
524
+ if (threadId && message.chat.is_forum) {
525
+ return `tg_${chatId}_topic_${threadId}`;
526
+ }
527
+ return `tg_${chatId}`;
528
+ }
529
+
530
+ private escapeHtml(text: string): string {
531
+ return text
532
+ .replace(/&/g, '&amp;')
533
+ .replace(/</g, '&lt;')
534
+ .replace(/>/g, '&gt;')
535
+ .replace(/"/g, '&quot;');
536
+ }
537
+
538
+ /**
539
+ * Convert basic Markdown to Telegram-safe HTML.
540
+ * Handles: bold, italic, code, code blocks, links.
541
+ */
542
+ private markdownToHtml(text: string): string {
543
+ let html = this.escapeHtml(text);
544
+
545
+ // Code blocks (```...```)
546
+ html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m, lang, code) => {
547
+ return `<pre${lang ? ` class="language-${lang}"` : ''}>${code}</pre>`;
548
+ });
549
+
550
+ // Inline code (`...`)
551
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
552
+
553
+ // Bold (**...**)
554
+ html = html.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
555
+
556
+ // Italic (*...*)
557
+ html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<i>$1</i>');
558
+
559
+ // Links [text](url)
560
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
561
+
562
+ return html;
563
+ }
564
+
565
+ /**
566
+ * Smart text splitting — prefer paragraph boundaries (blank lines) before hard length split.
567
+ * Aligned with OpenClaw's chunkMode="newline" behavior.
568
+ */
569
+ private smartSplit(text: string, maxLen: number): string[] {
570
+ if (text.length <= maxLen) return [text];
571
+
572
+ const parts: string[] = [];
573
+ let remaining = text;
574
+
575
+ while (remaining.length > maxLen) {
576
+ // Try to find a paragraph break (double newline) near the limit
577
+ let splitAt = remaining.lastIndexOf('\n\n', maxLen);
578
+ if (splitAt < maxLen * 0.3) {
579
+ // No good paragraph break, try single newline
580
+ splitAt = remaining.lastIndexOf('\n', maxLen);
581
+ }
582
+ if (splitAt < maxLen * 0.3) {
583
+ // No good newline, hard split at limit
584
+ splitAt = maxLen;
585
+ }
586
+
587
+ parts.push(remaining.slice(0, splitAt).trimEnd());
588
+ remaining = remaining.slice(splitAt).trimStart();
589
+ }
590
+
591
+ if (remaining) parts.push(remaining);
592
+ return parts;
308
593
  }
309
594
 
310
595
  private async apiCall(method: string, body?: Record<string, unknown>): Promise<any> {
@@ -315,19 +600,17 @@ export class TelegramChannel extends BaseChannel {
315
600
  headers: { 'Content-Type': 'application/json' },
316
601
  body: body ? JSON.stringify(body) : undefined,
317
602
  });
318
- return await res.json();
603
+ const data = await res.json() as { ok: boolean; result?: any; description?: string };
604
+ if (!data.ok) {
605
+ const err = new Error(`Telegram API ${method} failed: ${data.description}`);
606
+ (err as any).telegramError = data;
607
+ throw err;
608
+ }
609
+ return data;
319
610
  } catch (err) {
611
+ if ((err as any).telegramError) throw err;
320
612
  console.error(`[TelegramChannel] API call ${method} failed:`, err);
321
613
  throw err;
322
614
  }
323
615
  }
324
-
325
- private splitText(text: string, maxLen: number): string[] {
326
- if (text.length <= maxLen) return [text];
327
- const parts: string[] = [];
328
- for (let i = 0; i < text.length; i += maxLen) {
329
- parts.push(text.slice(i, i + maxLen));
330
- }
331
- return parts;
332
- }
333
616
  }