2ndbrain 2026.1.30 → 2026.1.32

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.
@@ -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;