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.
- package/dist/channels/telegram.d.ts +50 -13
- package/dist/channels/telegram.js +309 -84
- package/package.json +1 -1
- package/src/channels/telegram.ts +377 -94
|
@@ -1,33 +1,55 @@
|
|
|
1
1
|
import type { Message } from '../core/types';
|
|
2
2
|
import { BaseChannel } from './index';
|
|
3
3
|
/**
|
|
4
|
-
* Telegram channel —
|
|
4
|
+
* Telegram channel — production-quality Telegram bot integration.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|
|
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
|
-
|
|
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
|
|
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
|
-
// ───
|
|
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
|
|
158
|
-
if (message.text
|
|
159
|
-
await this.
|
|
160
|
-
|
|
210
|
+
// Handle commands
|
|
211
|
+
if (message.text?.startsWith('/')) {
|
|
212
|
+
const handled = await this.handleCommand(message);
|
|
213
|
+
if (handled)
|
|
214
|
+
return;
|
|
161
215
|
}
|
|
162
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
183
|
-
|
|
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.
|
|
252
|
+
this.sendTyping(message.chat.id, threadId).catch(() => { });
|
|
186
253
|
}, 4000);
|
|
187
254
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
212
|
-
|
|
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
|
-
|
|
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:
|
|
222
|
-
parse_mode: '
|
|
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
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
389
|
+
await this.sendFormattedMessage(chatId, fullText, replyTo, threadId);
|
|
268
390
|
}
|
|
269
391
|
throw err;
|
|
270
392
|
}
|
|
271
393
|
}
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
284
|
-
text:
|
|
285
|
-
parse_mode: '
|
|
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
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.
|
|
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, '&')
|
|
481
|
+
.replace(/</g, '<')
|
|
482
|
+
.replace(/>/g, '>')
|
|
483
|
+
.replace(/"/g, '"');
|
|
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
|
-
|
|
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
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
|
}
|