opc-agent 4.0.11 → 4.0.13

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