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,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
  }
@@ -349,7 +349,7 @@ class ClaudeCLIProvider implements LLMProvider {
349
349
  prompt += buildToolPrompt(options.tools);
350
350
  }
351
351
 
352
- const args = ['-p'];
352
+ const args = ['-p', '--no-project-context'];
353
353
  // Write system prompt to temp file to avoid shell escaping issues
354
354
  let tmpFile: string | undefined;
355
355
  if (systemPrompt) {
@@ -410,7 +410,7 @@ class ClaudeCLIProvider implements LLMProvider {
410
410
  }
411
411
 
412
412
  async *chatStream(messages: Message[], systemPrompt?: string): AsyncIterable<string> {
413
- const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
413
+ const args = ['-p', '--no-project-context', '--output-format', 'stream-json', '--include-partial-messages'];
414
414
  if (this.model) {
415
415
  args.push('--model', this.model);
416
416
  }