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.
- package/dist/channels/telegram.d.ts +50 -13
- package/dist/channels/telegram.js +309 -84
- package/dist/providers/index.js +2 -2
- package/package.json +1 -1
- package/src/channels/telegram.ts +377 -94
- package/src/providers/index.ts +2 -2
package/src/channels/telegram.ts
CHANGED
|
@@ -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 —
|
|
5
|
+
* Telegram channel — production-quality Telegram bot integration.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
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);
|
|
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
|
-
|
|
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
|
|
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
|
-
// ───
|
|
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
|
|
159
|
-
if (message.text
|
|
160
|
-
await this.
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
186
|
-
|
|
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.
|
|
270
|
+
this.sendTyping(message.chat.id, threadId).catch(() => {});
|
|
189
271
|
}, 4000);
|
|
190
272
|
|
|
191
273
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
214
|
-
|
|
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
|
-
|
|
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:
|
|
224
|
-
parse_mode: '
|
|
387
|
+
text: displayText,
|
|
388
|
+
parse_mode: 'HTML',
|
|
389
|
+
disable_web_page_preview: !this.linkPreview,
|
|
225
390
|
});
|
|
226
391
|
lastEditTime = Date.now();
|
|
227
|
-
} catch
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
430
|
+
await this.sendFormattedMessage(chatId, fullText, replyTo, threadId);
|
|
268
431
|
}
|
|
269
432
|
throw err;
|
|
270
433
|
}
|
|
271
434
|
}
|
|
272
435
|
|
|
273
|
-
//
|
|
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
|
|
281
|
-
const chunks = this.
|
|
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
|
-
|
|
287
|
-
text:
|
|
288
|
-
parse_mode: '
|
|
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
|
-
|
|
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.
|
|
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, '&')
|
|
533
|
+
.replace(/</g, '<')
|
|
534
|
+
.replace(/>/g, '>')
|
|
535
|
+
.replace(/"/g, '"');
|
|
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
|
-
|
|
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
|
}
|
package/src/providers/index.ts
CHANGED
|
@@ -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
|
}
|