2ndbrain 2026.1.30 → 2026.1.31
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/.claude/settings.local.json +16 -0
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/db/migrations/001_initial_schema.sql +91 -0
- package/doc/SPEC.md +896 -0
- package/hooks/auto-capture.sh +4 -0
- package/hooks/validate-command.sh +374 -0
- package/package.json +34 -20
- package/skills/journal/SKILL.md +112 -0
- package/skills/knowledge/SKILL.md +165 -0
- package/skills/project-manage/SKILL.md +216 -0
- package/skills/recall/SKILL.md +182 -0
- package/skills/system-ops/SKILL.md +161 -0
- package/src/attachments/store.js +167 -0
- package/src/claude/bridge.js +291 -0
- package/src/claude/conversation.js +219 -0
- package/src/config.js +90 -0
- package/src/db/migrate.js +94 -0
- package/src/db/pool.js +33 -0
- package/src/embeddings/engine.js +281 -0
- package/src/embeddings/worker.js +221 -0
- package/src/hooks/lifecycle.js +448 -0
- package/src/index.js +560 -0
- package/src/logging.js +91 -0
- package/src/mcp/config.js +75 -0
- package/src/mcp/embed-server.js +242 -0
- package/src/rate-limiter.js +114 -0
- package/src/telegram/bot.js +546 -0
- package/src/telegram/commands.js +440 -0
- package/src/web/server.js +880 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import hooks from '../hooks/lifecycle.js';
|
|
4
|
+
import logger from '../logging.js';
|
|
5
|
+
|
|
6
|
+
const TELEGRAM_API_BASE = 'https://api.telegram.org';
|
|
7
|
+
const MAX_MESSAGE_LENGTH = 4096;
|
|
8
|
+
const TYPING_INTERVAL_MS = 4_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Escape special characters for Telegram MarkdownV2 format.
|
|
12
|
+
*
|
|
13
|
+
* Characters that must be escaped:
|
|
14
|
+
* _ * [ ] ( ) ~ ` > # + - = | { } . !
|
|
15
|
+
*
|
|
16
|
+
* @param {string} text
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function escapeMarkdownV2(text) {
|
|
20
|
+
return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Telegram Bot adapter using the Bot API via native HTTPS.
|
|
25
|
+
*
|
|
26
|
+
* Connects to Telegram via long-polling (getUpdates). Handles inbound
|
|
27
|
+
* messages, access control, attachment extraction, and outbound message
|
|
28
|
+
* sending with MarkdownV2 formatting and chunking.
|
|
29
|
+
*
|
|
30
|
+
* @extends EventEmitter
|
|
31
|
+
*/
|
|
32
|
+
class TelegramBot extends EventEmitter {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} config
|
|
35
|
+
* @param {string} config.token - Telegram Bot API token
|
|
36
|
+
* @param {string[]} config.allowedUsers - Array of allowed Telegram user ID strings
|
|
37
|
+
*/
|
|
38
|
+
constructor(config) {
|
|
39
|
+
super();
|
|
40
|
+
|
|
41
|
+
if (!config.token) {
|
|
42
|
+
throw new Error('Telegram bot token is required');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @type {string} */
|
|
46
|
+
this._token = config.token;
|
|
47
|
+
|
|
48
|
+
/** @type {Set<string>} Allowed user IDs stored as strings for consistent comparison */
|
|
49
|
+
this._allowedUsers = new Set(
|
|
50
|
+
(config.allowedUsers || []).map((id) => String(id)),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** @type {number} Offset for getUpdates long-polling */
|
|
54
|
+
this._offset = 0;
|
|
55
|
+
|
|
56
|
+
/** @type {boolean} Whether polling is active */
|
|
57
|
+
this._polling = false;
|
|
58
|
+
|
|
59
|
+
/** @type {AbortController|null} Controller to cancel in-flight polling request */
|
|
60
|
+
this._abortController = null;
|
|
61
|
+
|
|
62
|
+
/** @type {Map<string, ReturnType<typeof setInterval>>} Active typing intervals by chatId */
|
|
63
|
+
this._typingIntervals = new Map();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Polling
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Start long-polling for updates from the Telegram API.
|
|
72
|
+
*/
|
|
73
|
+
startPolling() {
|
|
74
|
+
if (this._polling) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this._polling = true;
|
|
79
|
+
logger.info('telegram', 'Long-polling started');
|
|
80
|
+
this._poll();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stop long-polling.
|
|
85
|
+
*/
|
|
86
|
+
stopPolling() {
|
|
87
|
+
this._polling = false;
|
|
88
|
+
|
|
89
|
+
if (this._abortController) {
|
|
90
|
+
this._abortController.abort();
|
|
91
|
+
this._abortController = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Clear all typing intervals
|
|
95
|
+
for (const [, interval] of this._typingIntervals) {
|
|
96
|
+
clearInterval(interval);
|
|
97
|
+
}
|
|
98
|
+
this._typingIntervals.clear();
|
|
99
|
+
|
|
100
|
+
logger.info('telegram', 'Long-polling stopped');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Internal polling loop. Calls getUpdates with a long-poll timeout,
|
|
105
|
+
* processes each update, then recurses.
|
|
106
|
+
* @private
|
|
107
|
+
*/
|
|
108
|
+
async _poll() {
|
|
109
|
+
while (this._polling) {
|
|
110
|
+
try {
|
|
111
|
+
const updates = await this._apiCall('getUpdates', {
|
|
112
|
+
offset: this._offset,
|
|
113
|
+
timeout: 30,
|
|
114
|
+
allowed_updates: ['message'],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!this._polling) break;
|
|
118
|
+
|
|
119
|
+
if (Array.isArray(updates)) {
|
|
120
|
+
for (const update of updates) {
|
|
121
|
+
this._offset = update.update_id + 1;
|
|
122
|
+
await this._handleUpdate(update);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (!this._polling) break;
|
|
127
|
+
|
|
128
|
+
logger.error('telegram', `Polling error: ${err.message}`);
|
|
129
|
+
this.emit('error', err);
|
|
130
|
+
|
|
131
|
+
// Back off before retrying
|
|
132
|
+
await this._sleep(5_000);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Inbound message handling
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Process a single Telegram update object.
|
|
143
|
+
* @param {object} update
|
|
144
|
+
* @private
|
|
145
|
+
*/
|
|
146
|
+
async _handleUpdate(update) {
|
|
147
|
+
const message = update.message;
|
|
148
|
+
if (!message) return;
|
|
149
|
+
|
|
150
|
+
const userId = String(message.from?.id ?? '');
|
|
151
|
+
const chatId = String(message.chat?.id ?? '');
|
|
152
|
+
const messageId = message.message_id;
|
|
153
|
+
|
|
154
|
+
// Access control: silently drop messages from non-whitelisted users
|
|
155
|
+
if (!this._allowedUsers.has(userId)) {
|
|
156
|
+
logger.warn(
|
|
157
|
+
'telegram',
|
|
158
|
+
`Dropped message from unauthorized user ${userId} in chat ${chatId}`,
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Extract text content
|
|
164
|
+
const text = message.text || message.caption || '';
|
|
165
|
+
|
|
166
|
+
// Extract attachments
|
|
167
|
+
const attachments = this._extractAttachments(message);
|
|
168
|
+
|
|
169
|
+
const parsed = { chatId, userId, text, attachments, messageId };
|
|
170
|
+
|
|
171
|
+
// Run on_message_received lifecycle hook
|
|
172
|
+
const hookResult = await hooks.emit('on_message_received', {
|
|
173
|
+
message: parsed,
|
|
174
|
+
telegram_user_id: userId,
|
|
175
|
+
timestamp: Date.now(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (hookResult.aborted) {
|
|
179
|
+
logger.info(
|
|
180
|
+
'telegram',
|
|
181
|
+
`Message processing aborted by hook: ${hookResult.reason}`,
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.emit('message', parsed);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Extract attachment metadata from a Telegram message.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} message - Raw Telegram message object
|
|
193
|
+
* @returns {Array<{type: string, fileId: string, mimeType: string}>}
|
|
194
|
+
* @private
|
|
195
|
+
*/
|
|
196
|
+
_extractAttachments(message) {
|
|
197
|
+
const attachments = [];
|
|
198
|
+
|
|
199
|
+
// Photo: array of PhotoSize, take the largest (last)
|
|
200
|
+
if (message.photo && message.photo.length > 0) {
|
|
201
|
+
const largest = message.photo[message.photo.length - 1];
|
|
202
|
+
attachments.push({
|
|
203
|
+
type: 'photo',
|
|
204
|
+
fileId: largest.file_id,
|
|
205
|
+
mimeType: 'image/jpeg',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Document
|
|
210
|
+
if (message.document) {
|
|
211
|
+
attachments.push({
|
|
212
|
+
type: 'document',
|
|
213
|
+
fileId: message.document.file_id,
|
|
214
|
+
mimeType: message.document.mime_type || 'application/octet-stream',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Audio
|
|
219
|
+
if (message.audio) {
|
|
220
|
+
attachments.push({
|
|
221
|
+
type: 'audio',
|
|
222
|
+
fileId: message.audio.file_id,
|
|
223
|
+
mimeType: message.audio.mime_type || 'audio/mpeg',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Video
|
|
228
|
+
if (message.video) {
|
|
229
|
+
attachments.push({
|
|
230
|
+
type: 'video',
|
|
231
|
+
fileId: message.video.file_id,
|
|
232
|
+
mimeType: message.video.mime_type || 'video/mp4',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Voice
|
|
237
|
+
if (message.voice) {
|
|
238
|
+
attachments.push({
|
|
239
|
+
type: 'voice',
|
|
240
|
+
fileId: message.voice.file_id,
|
|
241
|
+
mimeType: message.voice.mime_type || 'audio/ogg',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return attachments;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Outbound messaging
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Send a text message to a chat. Automatically chunks messages that
|
|
254
|
+
* exceed Telegram's 4096-character limit. Uses MarkdownV2 parse mode.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} chatId
|
|
257
|
+
* @param {string} text
|
|
258
|
+
* @param {object} [options]
|
|
259
|
+
* @param {number} [options.reply_to_message_id] - Message ID to reply to
|
|
260
|
+
* @param {string} [options.parse_mode] - Override parse mode (default MarkdownV2)
|
|
261
|
+
* @returns {Promise<object[]>} Array of sent message results
|
|
262
|
+
*/
|
|
263
|
+
async sendMessage(chatId, text, options = {}) {
|
|
264
|
+
const parseMode = options.parse_mode ?? 'MarkdownV2';
|
|
265
|
+
const chunks = this._chunkText(text, MAX_MESSAGE_LENGTH);
|
|
266
|
+
const results = [];
|
|
267
|
+
|
|
268
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
269
|
+
const body = {
|
|
270
|
+
chat_id: chatId,
|
|
271
|
+
text: chunks[i],
|
|
272
|
+
parse_mode: parseMode,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Only attach reply_to on the first chunk
|
|
276
|
+
if (i === 0 && options.reply_to_message_id) {
|
|
277
|
+
body.reply_to_message_id = options.reply_to_message_id;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const result = await this._apiCall('sendMessage', body);
|
|
282
|
+
results.push(result);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
logger.error(
|
|
285
|
+
'telegram',
|
|
286
|
+
`Failed to send message chunk ${i + 1}/${chunks.length} to ${chatId}: ${err.message}`,
|
|
287
|
+
);
|
|
288
|
+
this.emit('error', err);
|
|
289
|
+
|
|
290
|
+
// If MarkdownV2 fails, retry the chunk as plain text
|
|
291
|
+
if (parseMode === 'MarkdownV2') {
|
|
292
|
+
try {
|
|
293
|
+
const fallback = await this._apiCall('sendMessage', {
|
|
294
|
+
chat_id: chatId,
|
|
295
|
+
text: chunks[i],
|
|
296
|
+
});
|
|
297
|
+
results.push(fallback);
|
|
298
|
+
} catch (fallbackErr) {
|
|
299
|
+
logger.error(
|
|
300
|
+
'telegram',
|
|
301
|
+
`Fallback plain-text send also failed: ${fallbackErr.message}`,
|
|
302
|
+
);
|
|
303
|
+
this.emit('error', fallbackErr);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return results;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Send a "typing" chat action indicator. Optionally starts a repeating
|
|
314
|
+
* interval that refreshes every 4 seconds (Telegram typing indicator
|
|
315
|
+
* expires after ~5s).
|
|
316
|
+
*
|
|
317
|
+
* @param {string} chatId
|
|
318
|
+
* @param {boolean} [repeat=false] - If true, repeat every 4s until stopTyping() is called
|
|
319
|
+
* @returns {Promise<void>}
|
|
320
|
+
*/
|
|
321
|
+
async sendTyping(chatId, repeat = false) {
|
|
322
|
+
try {
|
|
323
|
+
await this._apiCall('sendChatAction', {
|
|
324
|
+
chat_id: chatId,
|
|
325
|
+
action: 'typing',
|
|
326
|
+
});
|
|
327
|
+
} catch (err) {
|
|
328
|
+
logger.debug('telegram', `Failed to send typing indicator: ${err.message}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (repeat && !this._typingIntervals.has(chatId)) {
|
|
332
|
+
const interval = setInterval(async () => {
|
|
333
|
+
try {
|
|
334
|
+
await this._apiCall('sendChatAction', {
|
|
335
|
+
chat_id: chatId,
|
|
336
|
+
action: 'typing',
|
|
337
|
+
});
|
|
338
|
+
} catch {
|
|
339
|
+
// Silently ignore typing refresh failures
|
|
340
|
+
}
|
|
341
|
+
}, TYPING_INTERVAL_MS);
|
|
342
|
+
|
|
343
|
+
this._typingIntervals.set(chatId, interval);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Stop the repeating typing indicator for a chat.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} chatId
|
|
351
|
+
*/
|
|
352
|
+
stopTyping(chatId) {
|
|
353
|
+
const interval = this._typingIntervals.get(chatId);
|
|
354
|
+
if (interval) {
|
|
355
|
+
clearInterval(interval);
|
|
356
|
+
this._typingIntervals.delete(chatId);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// File handling
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Get the download URL for a file by its file_id.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} fileId
|
|
368
|
+
* @returns {Promise<string>} Full download URL
|
|
369
|
+
*/
|
|
370
|
+
async _getFileUrl(fileId) {
|
|
371
|
+
const file = await this._apiCall('getFile', { file_id: fileId });
|
|
372
|
+
return `${TELEGRAM_API_BASE}/file/bot${this._token}/${file.file_path}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Download a file from Telegram servers by file_id.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} fileId
|
|
379
|
+
* @returns {Promise<Buffer>} File contents as a Buffer
|
|
380
|
+
*/
|
|
381
|
+
async downloadFile(fileId) {
|
|
382
|
+
const url = await this._getFileUrl(fileId);
|
|
383
|
+
return this._httpsGet(url);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// HTTPS helpers
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Make a POST request to the Telegram Bot API.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} method - API method name (e.g. 'sendMessage')
|
|
394
|
+
* @param {object} body - JSON request body
|
|
395
|
+
* @returns {Promise<object>} The `result` field from the API response
|
|
396
|
+
* @private
|
|
397
|
+
*/
|
|
398
|
+
_apiCall(method, body = {}) {
|
|
399
|
+
return new Promise((resolve, reject) => {
|
|
400
|
+
const payload = JSON.stringify(body);
|
|
401
|
+
const url = new URL(`/bot${this._token}/${method}`, TELEGRAM_API_BASE);
|
|
402
|
+
|
|
403
|
+
const options = {
|
|
404
|
+
method: 'POST',
|
|
405
|
+
hostname: url.hostname,
|
|
406
|
+
path: url.pathname,
|
|
407
|
+
headers: {
|
|
408
|
+
'Content-Type': 'application/json',
|
|
409
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const req = https.request(options, (res) => {
|
|
414
|
+
const chunks = [];
|
|
415
|
+
|
|
416
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
417
|
+
|
|
418
|
+
res.on('end', () => {
|
|
419
|
+
try {
|
|
420
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
421
|
+
const data = JSON.parse(raw);
|
|
422
|
+
|
|
423
|
+
if (data.ok) {
|
|
424
|
+
resolve(data.result);
|
|
425
|
+
} else {
|
|
426
|
+
const err = new Error(
|
|
427
|
+
`Telegram API error: ${data.description || 'Unknown error'} (${data.error_code || 'N/A'})`,
|
|
428
|
+
);
|
|
429
|
+
err.code = data.error_code;
|
|
430
|
+
reject(err);
|
|
431
|
+
}
|
|
432
|
+
} catch (parseErr) {
|
|
433
|
+
reject(new Error(`Failed to parse Telegram API response: ${parseErr.message}`));
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
req.on('error', reject);
|
|
439
|
+
|
|
440
|
+
// Support aborting in-flight requests (used during polling shutdown)
|
|
441
|
+
if (this._abortController) {
|
|
442
|
+
const signal = this._abortController.signal;
|
|
443
|
+
if (signal.aborted) {
|
|
444
|
+
req.destroy();
|
|
445
|
+
reject(new Error('Request aborted'));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
signal.addEventListener('abort', () => req.destroy(), { once: true });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
req.write(payload);
|
|
452
|
+
req.end();
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Perform an HTTPS GET request and return the response body as a Buffer.
|
|
458
|
+
*
|
|
459
|
+
* @param {string} url
|
|
460
|
+
* @returns {Promise<Buffer>}
|
|
461
|
+
* @private
|
|
462
|
+
*/
|
|
463
|
+
_httpsGet(url) {
|
|
464
|
+
return new Promise((resolve, reject) => {
|
|
465
|
+
https.get(url, (res) => {
|
|
466
|
+
// Follow redirects (Telegram may redirect file downloads)
|
|
467
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
468
|
+
this._httpsGet(res.headers.location).then(resolve, reject);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (res.statusCode !== 200) {
|
|
473
|
+
reject(new Error(`HTTP ${res.statusCode} when downloading file`));
|
|
474
|
+
res.resume(); // Drain response
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const chunks = [];
|
|
479
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
480
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
481
|
+
res.on('error', reject);
|
|
482
|
+
}).on('error', reject);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// Utilities
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Split text into chunks that fit within Telegram's message size limit.
|
|
492
|
+
* Tries to split on newline boundaries for cleaner output.
|
|
493
|
+
*
|
|
494
|
+
* @param {string} text
|
|
495
|
+
* @param {number} maxLen
|
|
496
|
+
* @returns {string[]}
|
|
497
|
+
* @private
|
|
498
|
+
*/
|
|
499
|
+
_chunkText(text, maxLen) {
|
|
500
|
+
if (text.length <= maxLen) {
|
|
501
|
+
return [text];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const chunks = [];
|
|
505
|
+
let remaining = text;
|
|
506
|
+
|
|
507
|
+
while (remaining.length > 0) {
|
|
508
|
+
if (remaining.length <= maxLen) {
|
|
509
|
+
chunks.push(remaining);
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Try to find a newline to split on within the limit
|
|
514
|
+
let splitAt = remaining.lastIndexOf('\n', maxLen);
|
|
515
|
+
|
|
516
|
+
// Fall back to splitting at a space
|
|
517
|
+
if (splitAt <= 0) {
|
|
518
|
+
splitAt = remaining.lastIndexOf(' ', maxLen);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Last resort: hard split
|
|
522
|
+
if (splitAt <= 0) {
|
|
523
|
+
splitAt = maxLen;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
527
|
+
remaining = remaining.slice(splitAt).replace(/^\n/, '');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return chunks;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Utility sleep for back-off delays.
|
|
535
|
+
*
|
|
536
|
+
* @param {number} ms
|
|
537
|
+
* @returns {Promise<void>}
|
|
538
|
+
* @private
|
|
539
|
+
*/
|
|
540
|
+
_sleep(ms) {
|
|
541
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export { TelegramBot, escapeMarkdownV2 };
|
|
546
|
+
export default TelegramBot;
|