@goodandready/dsh-messenger-gateway 0.3.19 → 0.3.20
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/lib/adapters/telegram.js +708 -690
- package/lib/commands.js +70 -47
- package/lib/config.js +104 -98
- package/lib/gateway.js +1539 -1406
- package/lib/index.js +500 -488
- package/package.json +1 -1
package/lib/adapters/telegram.js
CHANGED
|
@@ -1,690 +1,708 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises'
|
|
2
|
-
import {
|
|
3
|
-
IMAGE_EXT_TO_MIME, VIDEO_EXT_TO_MIME, basename, cacheName, classifyDocument,
|
|
4
|
-
extOf, saveToCache, safeName,
|
|
5
|
-
} from '../media.js'
|
|
6
|
-
import { TEXT_INJECT_EXTS } from '../documents.js'
|
|
7
|
-
import { splitText } from '../text.js'
|
|
8
|
-
import { prepareTelegramText } from '../telegram-format.js'
|
|
9
|
-
import { normalizeTelegramCommands } from '../commands.js'
|
|
10
|
-
import { normalizeThreadId, telegramThreadParams } from '../topics.js'
|
|
11
|
-
import {
|
|
12
|
-
shouldProcessTelegramMessage, stripBotCommandSuffix,
|
|
13
|
-
} from '../groups.js'
|
|
14
|
-
import { isResendSafeNetworkError, isPollingConflict, isTopicGoneError, computePollBackoffMs } from '../telegram-errors.js'
|
|
15
|
-
|
|
16
|
-
const API = 'https://api.telegram.org'
|
|
17
|
-
const TELEGRAM_MAX = 4096
|
|
18
|
-
|
|
19
|
-
export function buildQuickActionsKeyboard() {
|
|
20
|
-
return {
|
|
21
|
-
keyboard: [
|
|
22
|
-
[{ text: '🔄 /new' }, { text: '🛑 /stop' }],
|
|
23
|
-
[{ text: '🎙️ /voice' }, { text: '📊 /status' }],
|
|
24
|
-
],
|
|
25
|
-
resize_keyboard: true,
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const REMOVE_REPLY_KEYBOARD = { remove_keyboard: true }
|
|
30
|
-
|
|
31
|
-
export class TelegramAdapter {
|
|
32
|
-
constructor(opts) {
|
|
33
|
-
this.name = 'telegram'
|
|
34
|
-
this.token = String(opts.botToken || '').trim()
|
|
35
|
-
this.allowedUserIds = (opts.allowedUserIds || []).map(Number).filter((n) => Number.isFinite(n))
|
|
36
|
-
this.timeoutSeconds = Number(opts.timeoutSeconds) || 50
|
|
37
|
-
this.pollIntervalMs = Number(opts.pollIntervalMs) || 500
|
|
38
|
-
this.media = opts.media || {}
|
|
39
|
-
this.onMessage = opts.onMessage
|
|
40
|
-
this.onCallback = opts.onCallback
|
|
41
|
-
this.onUnauthorized = opts.onUnauthorized
|
|
42
|
-
this.isUserAllowed = opts.isUserAllowed
|
|
43
|
-
this.logger = opts.logger
|
|
44
|
-
this.commands = normalizeTelegramCommands(opts.commands)
|
|
45
|
-
this.textFormat = opts.textFormat === 'plain' ? 'plain' : 'html'
|
|
46
|
-
this.groupsEnabled = opts.groupsEnabled !== false
|
|
47
|
-
this.groupRequireMention = opts.groupRequireMention !== false
|
|
48
|
-
this.reactionsEnabled = opts.reactionsEnabled !== false
|
|
49
|
-
this.quickActions = opts.quickActions === true
|
|
50
|
-
this.artifactPreviews = opts.artifactPreviews !== false
|
|
51
|
-
this.transport = opts.transport === 'webhook' ? 'webhook' : 'poll'
|
|
52
|
-
this.statusIndicator = opts.statusIndicator === true
|
|
53
|
-
this.statusOnline = String(opts.statusOnline || 'Online')
|
|
54
|
-
this.statusOffline = String(opts.statusOffline || 'Offline')
|
|
55
|
-
this.sendRetryMax = 2
|
|
56
|
-
this.sendRetryBaseMs = 400
|
|
57
|
-
this.pollingConflict = false
|
|
58
|
-
this.pollErrorCount = 0
|
|
59
|
-
this.webhookUrl = String(opts.webhookUrl || '').trim()
|
|
60
|
-
this.webhookSecret = String(opts.webhookSecret || '').trim()
|
|
61
|
-
this.offset = 0
|
|
62
|
-
this.stopped = false
|
|
63
|
-
this.pollTimer = undefined
|
|
64
|
-
this.botId = 0
|
|
65
|
-
this.botUsername = ''
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
setAllowedUserIds(ids) {
|
|
69
|
-
this.allowedUserIds = (ids || []).map(Number).filter((n) => Number.isFinite(n))
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async call(method, params = {}) {
|
|
73
|
-
const timeoutMs = (this.timeoutSeconds * 1000) + 15000
|
|
74
|
-
const res = await fetch(`${API}/bot${this.token}/${method}`, {
|
|
75
|
-
method: 'POST',
|
|
76
|
-
headers: { 'Content-Type': 'application/json' },
|
|
77
|
-
body: JSON.stringify(params),
|
|
78
|
-
keepalive: true,
|
|
79
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
80
|
-
})
|
|
81
|
-
const json = await res.json().catch(() => ({}))
|
|
82
|
-
if (!res.ok || json.ok === false) throw new Error(`telegram ${method}: ${json.description || res.status}`)
|
|
83
|
-
return json.result
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async callMultipart(method, form) {
|
|
87
|
-
const timeoutMs = (this.timeoutSeconds * 1000) + 30000
|
|
88
|
-
const res = await fetch(`${API}/bot${this.token}/${method}`, {
|
|
89
|
-
method: 'POST',
|
|
90
|
-
body: form,
|
|
91
|
-
keepalive: true,
|
|
92
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
93
|
-
})
|
|
94
|
-
const json = await res.json().catch(() => ({}))
|
|
95
|
-
if (!res.ok || json.ok === false) throw new Error(`telegram ${method}: ${json.description || res.status}`)
|
|
96
|
-
return json.result
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Retry a send only on resend-safe network errors (request never reached Telegram).
|
|
100
|
-
// Permanent errors (4xx/5xx) and ambiguous timeouts are not retried to avoid duplicates.
|
|
101
|
-
async sendWithRetry(method, params, { multipart = false } = {}) {
|
|
102
|
-
const fn = () => (multipart ? this.callMultipart(method, params) : this.call(method, params))
|
|
103
|
-
let lastErr
|
|
104
|
-
for (let attempt = 0; attempt <= this.sendRetryMax; attempt++) {
|
|
105
|
-
try {
|
|
106
|
-
return await fn()
|
|
107
|
-
} catch (err) {
|
|
108
|
-
lastErr = err
|
|
109
|
-
if (!isResendSafeNetworkError(err) || attempt >= this.sendRetryMax) throw err
|
|
110
|
-
this.logger?.warn?.(`telegram ${method} resend-safe network error (attempt ${attempt + 1}/${this.sendRetryMax}), retrying: ${err.message}`)
|
|
111
|
-
await new Promise((r) => setTimeout(r, this.sendRetryBaseMs * (attempt + 1)))
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
throw lastErr
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async getFile(fileId) { return this.call('getFile', { file_id: fileId }) }
|
|
118
|
-
|
|
119
|
-
async downloadFile(filePath) {
|
|
120
|
-
const timeoutMs = (this.timeoutSeconds * 1000) + 30000
|
|
121
|
-
const res = await fetch(`${API}/file/bot${this.token}/${filePath}`, {
|
|
122
|
-
keepalive: true,
|
|
123
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
124
|
-
})
|
|
125
|
-
if (!res.ok) throw new Error(`telegram download HTTP ${res.status}`)
|
|
126
|
-
return new Uint8Array(await res.arrayBuffer())
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async registerCommands() {
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
this.logger?.
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
},
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
} else {
|
|
393
|
-
const { path } = await this.downloadByFileId(
|
|
394
|
-
attachments.push({ kind: '
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
if (msg.
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (msg.
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
form
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
:
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
}
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import {
|
|
3
|
+
IMAGE_EXT_TO_MIME, VIDEO_EXT_TO_MIME, basename, cacheName, classifyDocument,
|
|
4
|
+
extOf, saveToCache, safeName,
|
|
5
|
+
} from '../media.js'
|
|
6
|
+
import { TEXT_INJECT_EXTS } from '../documents.js'
|
|
7
|
+
import { splitText } from '../text.js'
|
|
8
|
+
import { prepareTelegramText } from '../telegram-format.js'
|
|
9
|
+
import { normalizeTelegramCommands } from '../commands.js'
|
|
10
|
+
import { normalizeThreadId, telegramThreadParams } from '../topics.js'
|
|
11
|
+
import {
|
|
12
|
+
shouldProcessTelegramMessage, stripBotCommandSuffix,
|
|
13
|
+
} from '../groups.js'
|
|
14
|
+
import { isResendSafeNetworkError, isPollingConflict, isTopicGoneError, computePollBackoffMs } from '../telegram-errors.js'
|
|
15
|
+
|
|
16
|
+
const API = 'https://api.telegram.org'
|
|
17
|
+
const TELEGRAM_MAX = 4096
|
|
18
|
+
|
|
19
|
+
export function buildQuickActionsKeyboard() {
|
|
20
|
+
return {
|
|
21
|
+
keyboard: [
|
|
22
|
+
[{ text: '🔄 /new' }, { text: '🛑 /stop' }],
|
|
23
|
+
[{ text: '🎙️ /voice' }, { text: '📊 /status' }],
|
|
24
|
+
],
|
|
25
|
+
resize_keyboard: true,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const REMOVE_REPLY_KEYBOARD = { remove_keyboard: true }
|
|
30
|
+
|
|
31
|
+
export class TelegramAdapter {
|
|
32
|
+
constructor(opts) {
|
|
33
|
+
this.name = 'telegram'
|
|
34
|
+
this.token = String(opts.botToken || '').trim()
|
|
35
|
+
this.allowedUserIds = (opts.allowedUserIds || []).map(Number).filter((n) => Number.isFinite(n))
|
|
36
|
+
this.timeoutSeconds = Number(opts.timeoutSeconds) || 50
|
|
37
|
+
this.pollIntervalMs = Number(opts.pollIntervalMs) || 500
|
|
38
|
+
this.media = opts.media || {}
|
|
39
|
+
this.onMessage = opts.onMessage
|
|
40
|
+
this.onCallback = opts.onCallback
|
|
41
|
+
this.onUnauthorized = opts.onUnauthorized
|
|
42
|
+
this.isUserAllowed = opts.isUserAllowed
|
|
43
|
+
this.logger = opts.logger
|
|
44
|
+
this.commands = normalizeTelegramCommands(opts.commands)
|
|
45
|
+
this.textFormat = opts.textFormat === 'plain' ? 'plain' : 'html'
|
|
46
|
+
this.groupsEnabled = opts.groupsEnabled !== false
|
|
47
|
+
this.groupRequireMention = opts.groupRequireMention !== false
|
|
48
|
+
this.reactionsEnabled = opts.reactionsEnabled !== false
|
|
49
|
+
this.quickActions = opts.quickActions === true
|
|
50
|
+
this.artifactPreviews = opts.artifactPreviews !== false
|
|
51
|
+
this.transport = opts.transport === 'webhook' ? 'webhook' : 'poll'
|
|
52
|
+
this.statusIndicator = opts.statusIndicator === true
|
|
53
|
+
this.statusOnline = String(opts.statusOnline || 'Online')
|
|
54
|
+
this.statusOffline = String(opts.statusOffline || 'Offline')
|
|
55
|
+
this.sendRetryMax = 2
|
|
56
|
+
this.sendRetryBaseMs = 400
|
|
57
|
+
this.pollingConflict = false
|
|
58
|
+
this.pollErrorCount = 0
|
|
59
|
+
this.webhookUrl = String(opts.webhookUrl || '').trim()
|
|
60
|
+
this.webhookSecret = String(opts.webhookSecret || '').trim()
|
|
61
|
+
this.offset = 0
|
|
62
|
+
this.stopped = false
|
|
63
|
+
this.pollTimer = undefined
|
|
64
|
+
this.botId = 0
|
|
65
|
+
this.botUsername = ''
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setAllowedUserIds(ids) {
|
|
69
|
+
this.allowedUserIds = (ids || []).map(Number).filter((n) => Number.isFinite(n))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async call(method, params = {}) {
|
|
73
|
+
const timeoutMs = (this.timeoutSeconds * 1000) + 15000
|
|
74
|
+
const res = await fetch(`${API}/bot${this.token}/${method}`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify(params),
|
|
78
|
+
keepalive: true,
|
|
79
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
80
|
+
})
|
|
81
|
+
const json = await res.json().catch(() => ({}))
|
|
82
|
+
if (!res.ok || json.ok === false) throw new Error(`telegram ${method}: ${json.description || res.status}`)
|
|
83
|
+
return json.result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async callMultipart(method, form) {
|
|
87
|
+
const timeoutMs = (this.timeoutSeconds * 1000) + 30000
|
|
88
|
+
const res = await fetch(`${API}/bot${this.token}/${method}`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
body: form,
|
|
91
|
+
keepalive: true,
|
|
92
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
93
|
+
})
|
|
94
|
+
const json = await res.json().catch(() => ({}))
|
|
95
|
+
if (!res.ok || json.ok === false) throw new Error(`telegram ${method}: ${json.description || res.status}`)
|
|
96
|
+
return json.result
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Retry a send only on resend-safe network errors (request never reached Telegram).
|
|
100
|
+
// Permanent errors (4xx/5xx) and ambiguous timeouts are not retried to avoid duplicates.
|
|
101
|
+
async sendWithRetry(method, params, { multipart = false } = {}) {
|
|
102
|
+
const fn = () => (multipart ? this.callMultipart(method, params) : this.call(method, params))
|
|
103
|
+
let lastErr
|
|
104
|
+
for (let attempt = 0; attempt <= this.sendRetryMax; attempt++) {
|
|
105
|
+
try {
|
|
106
|
+
return await fn()
|
|
107
|
+
} catch (err) {
|
|
108
|
+
lastErr = err
|
|
109
|
+
if (!isResendSafeNetworkError(err) || attempt >= this.sendRetryMax) throw err
|
|
110
|
+
this.logger?.warn?.(`telegram ${method} resend-safe network error (attempt ${attempt + 1}/${this.sendRetryMax}), retrying: ${err.message}`)
|
|
111
|
+
await new Promise((r) => setTimeout(r, this.sendRetryBaseMs * (attempt + 1)))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw lastErr
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async getFile(fileId) { return this.call('getFile', { file_id: fileId }) }
|
|
118
|
+
|
|
119
|
+
async downloadFile(filePath) {
|
|
120
|
+
const timeoutMs = (this.timeoutSeconds * 1000) + 30000
|
|
121
|
+
const res = await fetch(`${API}/file/bot${this.token}/${filePath}`, {
|
|
122
|
+
keepalive: true,
|
|
123
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
124
|
+
})
|
|
125
|
+
if (!res.ok) throw new Error(`telegram download HTTP ${res.status}`)
|
|
126
|
+
return new Uint8Array(await res.arrayBuffer())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async registerCommands(commands) {
|
|
130
|
+
if (commands && Array.isArray(commands)) {
|
|
131
|
+
this.commands = normalizeTelegramCommands(commands)
|
|
132
|
+
}
|
|
133
|
+
if (!this.commands.length) return
|
|
134
|
+
await this.call('setMyCommands', { commands: this.commands })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async createForumTopic(chatId, name, options = {}) {
|
|
138
|
+
return this.call('createForumTopic', {
|
|
139
|
+
chat_id: chatId,
|
|
140
|
+
name: String(name || '').slice(0, 128),
|
|
141
|
+
...options,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async closeForumTopic(chatId, messageThreadId) {
|
|
146
|
+
return this.call('closeForumTopic', {
|
|
147
|
+
chat_id: chatId,
|
|
148
|
+
message_thread_id: messageThreadId,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Bots have no presence dot; the short description is the closest surface.
|
|
153
|
+
// Opt-in only — it mutates the bot's global profile visible to all users.
|
|
154
|
+
async setStatusIndicator(text) {
|
|
155
|
+
if (!this.statusIndicator) return
|
|
156
|
+
try {
|
|
157
|
+
await this.call('setMyShortDescription', { short_description: String(text || '').slice(0, 120) })
|
|
158
|
+
} catch (err) {
|
|
159
|
+
this.logger?.warn?.(`telegram setMyShortDescription: ${err.message}`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async start() {
|
|
164
|
+
if (!this.token) throw new Error('telegram bot token is empty')
|
|
165
|
+
this.stopped = false
|
|
166
|
+
try {
|
|
167
|
+
const me = await this.call('getMe')
|
|
168
|
+
this.botId = Number(me.id) || 0
|
|
169
|
+
this.botUsername = String(me.username || '')
|
|
170
|
+
this.logger?.info?.(`dsh-messenger-gateway: telegram bot @${this.botUsername} (${this.botId})`)
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.logger?.warn?.(`dsh-messenger-gateway: telegram getMe: ${err.message}`)
|
|
173
|
+
}
|
|
174
|
+
if (this.statusIndicator) await this.setStatusIndicator(this.statusOnline)
|
|
175
|
+
try {
|
|
176
|
+
await this.registerCommands()
|
|
177
|
+
this.logger?.info?.(`dsh-messenger-gateway: telegram commands registered (${this.commands.length})`)
|
|
178
|
+
} catch (err) {
|
|
179
|
+
this.logger?.warn?.(`dsh-messenger-gateway: telegram setMyCommands: ${err.message}`)
|
|
180
|
+
}
|
|
181
|
+
if (this.transport === 'webhook') {
|
|
182
|
+
if (!this.webhookUrl) throw new Error('telegram webhookUrl is required for webhook transport')
|
|
183
|
+
try {
|
|
184
|
+
await this.call('deleteWebhook', { drop_pending_updates: false })
|
|
185
|
+
} catch {}
|
|
186
|
+
const params = {
|
|
187
|
+
url: this.webhookUrl,
|
|
188
|
+
allowed_updates: ['message', 'callback_query'],
|
|
189
|
+
drop_pending_updates: false,
|
|
190
|
+
}
|
|
191
|
+
if (this.webhookSecret) params.secret_token = this.webhookSecret
|
|
192
|
+
await this.call('setWebhook', params)
|
|
193
|
+
this.logger?.info?.(`dsh-messenger-gateway: telegram webhook set → ${this.webhookUrl}`)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
try { await this.call('deleteWebhook', { drop_pending_updates: false }) } catch {}
|
|
197
|
+
this.poll()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
stop() {
|
|
201
|
+
this.stopped = true
|
|
202
|
+
if (this.pollTimer) clearTimeout(this.pollTimer)
|
|
203
|
+
if (this.statusIndicator) this.setStatusIndicator(this.statusOffline).catch(() => {})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
schedulePoll(delayMs) {
|
|
207
|
+
if (this.stopped) return
|
|
208
|
+
const delay = delayMs !== undefined ? delayMs : this.pollIntervalMs
|
|
209
|
+
this.pollTimer = setTimeout(() => this.poll(), delay)
|
|
210
|
+
this.pollTimer.unref?.()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async poll() {
|
|
214
|
+
if (this.stopped) return
|
|
215
|
+
try {
|
|
216
|
+
const updates = await this.call('getUpdates', {
|
|
217
|
+
timeout: this.timeoutSeconds,
|
|
218
|
+
offset: this.offset,
|
|
219
|
+
allowed_updates: ['message', 'callback_query'],
|
|
220
|
+
})
|
|
221
|
+
this.pollingConflict = false
|
|
222
|
+
this.pollErrorCount = 0
|
|
223
|
+
for (const update of updates || []) {
|
|
224
|
+
this.offset = Math.max(this.offset, update.update_id + 1)
|
|
225
|
+
await this.dispatchUpdate(update)
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (!this.stopped) {
|
|
229
|
+
if (isPollingConflict(e)) {
|
|
230
|
+
this.pollingConflict = true
|
|
231
|
+
this.logger?.error?.(`poll: TELEGRAM CONFLICT — another bot instance is polling the same token. Stop the duplicate instance. (${e.message})`)
|
|
232
|
+
} else {
|
|
233
|
+
this.logger?.warn?.(`poll: ${e.message}`)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this.schedulePoll()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async dispatchUpdate(update) {
|
|
241
|
+
if (update.callback_query) {
|
|
242
|
+
const fromId = update.callback_query.from?.id
|
|
243
|
+
if (fromId && !this.allowed(fromId)) {
|
|
244
|
+
await this.call('answerCallbackQuery', {
|
|
245
|
+
callback_query_id: update.callback_query.id,
|
|
246
|
+
text: 'Нет доступа',
|
|
247
|
+
show_alert: true,
|
|
248
|
+
}).catch(() => {})
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
try { await this.onCallback?.(this.wrapCallback(update.callback_query)) } catch (e) {
|
|
252
|
+
this.logger?.warn?.(`callback: ${e.message}`)
|
|
253
|
+
}
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const msg = update.message
|
|
257
|
+
if (!msg) return
|
|
258
|
+
try { await this.handleMessage(msg) } catch (e) {
|
|
259
|
+
this.logger?.warn?.(`message: ${e.message}`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** HTTP webhook entry (caller verifies secret). */
|
|
264
|
+
async handleWebhookUpdate(update) {
|
|
265
|
+
if (this.stopped) return
|
|
266
|
+
await this.dispatchUpdate(update)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
wrapCallback(cq) {
|
|
270
|
+
const chatId = cq.message?.chat?.id
|
|
271
|
+
const messageId = cq.message?.message_id
|
|
272
|
+
return {
|
|
273
|
+
platform: 'telegram', chatId, threadId: cq.message?.message_thread_id || 0, userId: cq.from?.id, data: cq.data, callbackQueryId: cq.id,
|
|
274
|
+
message: cq.message,
|
|
275
|
+
answer: async (text) => this.call('answerCallbackQuery', { callback_query_id: cq.id, text: text || '' }),
|
|
276
|
+
editMessage: async (text, replyMarkup) => {
|
|
277
|
+
const { text: formatted, parseMode } = this.formatOutgoingText(text)
|
|
278
|
+
const params = { chat_id: chatId, message_id: messageId, text: formatted, reply_markup: replyMarkup }
|
|
279
|
+
if (parseMode) params.parse_mode = parseMode
|
|
280
|
+
try {
|
|
281
|
+
return await this.call('editMessageText', params)
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (!parseMode) throw err
|
|
284
|
+
return this.call('editMessageText', { chat_id: chatId, message_id: messageId, text, reply_markup: replyMarkup })
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
editReplyMarkup: async (replyMarkup) => {
|
|
288
|
+
return this.call('editMessageReplyMarkup', {
|
|
289
|
+
chat_id: chatId,
|
|
290
|
+
message_id: messageId,
|
|
291
|
+
reply_markup: replyMarkup,
|
|
292
|
+
})
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
allowed(userId) {
|
|
298
|
+
if (typeof this.isUserAllowed === 'function') return this.isUserAllowed(userId)
|
|
299
|
+
return this.allowedUserIds.length === 0 || this.allowedUserIds.includes(Number(userId))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async downloadByFileId(fileId, prefix, ext, name = '') {
|
|
303
|
+
const file = await this.getFile(fileId)
|
|
304
|
+
const bytes = await this.downloadFile(file.file_path)
|
|
305
|
+
const resolvedExt = ext || extOf(file.file_path, '') || ''
|
|
306
|
+
const path = saveToCache(this.media.cacheDir, cacheName(prefix, resolvedExt, name), bytes)
|
|
307
|
+
return { path, bytes, file }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async setReaction(chatId, messageId, emoji) {
|
|
311
|
+
if (!this.reactionsEnabled || !messageId) return
|
|
312
|
+
try {
|
|
313
|
+
await this.call('setMessageReaction', {
|
|
314
|
+
chat_id: chatId,
|
|
315
|
+
message_id: messageId,
|
|
316
|
+
reaction: emoji ? [{ type: 'emoji', emoji }] : [],
|
|
317
|
+
})
|
|
318
|
+
} catch (e) {
|
|
319
|
+
this.logger?.warn?.(`reaction: ${e.message}`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async handleMessage(msg) {
|
|
324
|
+
const chatId = msg.chat.id
|
|
325
|
+
const chatType = msg.chat?.type || 'private'
|
|
326
|
+
const userId = msg.from?.id ?? chatId
|
|
327
|
+
let text = msg.text ?? msg.caption ?? ''
|
|
328
|
+
const entities = msg.entities || msg.caption_entities || []
|
|
329
|
+
const gate = shouldProcessTelegramMessage({
|
|
330
|
+
chatType,
|
|
331
|
+
text,
|
|
332
|
+
entities,
|
|
333
|
+
replyTo: msg.reply_to_message,
|
|
334
|
+
botId: this.botId,
|
|
335
|
+
botUsername: this.botUsername,
|
|
336
|
+
groupsEnabled: this.groupsEnabled,
|
|
337
|
+
requireMention: this.groupRequireMention,
|
|
338
|
+
})
|
|
339
|
+
if (!gate.ok) return
|
|
340
|
+
|
|
341
|
+
if (!this.allowed(userId)) {
|
|
342
|
+
if (chatType !== 'private') return
|
|
343
|
+
if (this.onUnauthorized) {
|
|
344
|
+
await this.onUnauthorized({
|
|
345
|
+
platform: 'telegram', chatId, userId, threadId: msg.message_thread_id || 0,
|
|
346
|
+
username: msg.from?.username || '',
|
|
347
|
+
reply: async (payload) => this.sendReply(chatId, msg.message_id, payload, msg.message_thread_id || 0),
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
text = stripBotCommandSuffix(text, this.botUsername)
|
|
354
|
+
const threadId = msg.message_thread_id || 0
|
|
355
|
+
const maxDocBytes = this.media.maxDocBytes ?? 20 * 1024 * 1024
|
|
356
|
+
const maxTextInjectBytes = this.media.maxTextInjectBytes ?? 100 * 1024
|
|
357
|
+
const attachments = []
|
|
358
|
+
const replyMsg = msg.reply_to_message
|
|
359
|
+
let replyText = ''
|
|
360
|
+
if (replyMsg) {
|
|
361
|
+
const quoted = replyMsg.text ?? replyMsg.caption ?? ''
|
|
362
|
+
const quoteFrag = msg.quote?.text || ''
|
|
363
|
+
replyText = quoteFrag
|
|
364
|
+
? `${quoted}${quoted ? '\n' : ''}[цитата: ${quoteFrag}]`
|
|
365
|
+
: quoted
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (msg.photo?.length) {
|
|
369
|
+
const largest = msg.photo[msg.photo.length - 1]
|
|
370
|
+
const { path, file } = await this.downloadByFileId(largest.file_id, 'photo', extOf('', '') || '.jpg')
|
|
371
|
+
const ext = extOf(file.file_path, '') || '.jpg'
|
|
372
|
+
attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || 'image/jpeg' })
|
|
373
|
+
}
|
|
374
|
+
if (msg.sticker) {
|
|
375
|
+
const st = msg.sticker
|
|
376
|
+
if (st.is_video) {
|
|
377
|
+
try {
|
|
378
|
+
const { path } = await this.downloadByFileId(st.file_id, 'sticker-video', '.webm', st.file_unique_id || '')
|
|
379
|
+
attachments.push({ kind: 'animation', path, mime: 'video/webm', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webm` })
|
|
380
|
+
if (st.emoji) text = `${text}\n[Видео-стикер ${st.emoji}]`.trim()
|
|
381
|
+
} catch (e) {
|
|
382
|
+
text = `${text}\n[Видео-стикер ${st.emoji || ''} (не скачан: ${e.message})]`.trim()
|
|
383
|
+
}
|
|
384
|
+
} else if (st.is_animated) {
|
|
385
|
+
try {
|
|
386
|
+
const { path } = await this.downloadByFileId(st.file_id, 'sticker-anim', '.tgs', st.file_unique_id || '')
|
|
387
|
+
attachments.push({ kind: 'document', path, mime: 'application/x-tgsticker', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.tgs` })
|
|
388
|
+
if (st.emoji) text = `${text}\n[Анимированный стикер ${st.emoji}]`.trim()
|
|
389
|
+
} catch (e) {
|
|
390
|
+
text = `${text}\n[Анимированный стикер ${st.emoji || ''} (не скачан: ${e.message})]`.trim()
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
const { path } = await this.downloadByFileId(st.file_id, 'sticker', '.webp', st.file_unique_id || '')
|
|
394
|
+
attachments.push({ kind: 'sticker', path, mime: 'image/webp', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webp` })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (msg.voice) {
|
|
398
|
+
const { path } = await this.downloadByFileId(msg.voice.file_id, 'voice', '.ogg')
|
|
399
|
+
attachments.push({ kind: 'voice', path, mime: 'audio/ogg' })
|
|
400
|
+
}
|
|
401
|
+
if (msg.audio) {
|
|
402
|
+
const ext = extOf(msg.audio.file_name, msg.audio.mime_type) || '.mp3'
|
|
403
|
+
const { path } = await this.downloadByFileId(msg.audio.file_id, 'audio', ext, msg.audio.file_name || '')
|
|
404
|
+
attachments.push({ kind: 'audio', path, mime: msg.audio.mime_type || 'audio/mpeg' })
|
|
405
|
+
}
|
|
406
|
+
if (msg.video) {
|
|
407
|
+
const ext = extOf(msg.video.file_name, msg.video.mime_type) || '.mp4'
|
|
408
|
+
if ((msg.video.file_size || 0) > maxDocBytes) {
|
|
409
|
+
text = `${text}\n[Video too large]`.trim()
|
|
410
|
+
} else {
|
|
411
|
+
const { path } = await this.downloadByFileId(msg.video.file_id, 'video', ext, msg.video.file_name || '')
|
|
412
|
+
attachments.push({ kind: 'video', path, mime: msg.video.mime_type || VIDEO_EXT_TO_MIME[ext] || 'video/mp4', name: msg.video.file_name || basename(path) })
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (msg.video_note) {
|
|
416
|
+
const vn = msg.video_note
|
|
417
|
+
if ((vn.file_size || 0) > maxDocBytes) {
|
|
418
|
+
text = `${text}\n[Video note too large]`.trim()
|
|
419
|
+
} else {
|
|
420
|
+
const { path } = await this.downloadByFileId(vn.file_id, 'videonote', '.mp4')
|
|
421
|
+
attachments.push({ kind: 'video', path, mime: 'video/mp4', name: 'video_note.mp4' })
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (msg.animation) {
|
|
425
|
+
const an = msg.animation
|
|
426
|
+
const ext = extOf(an.file_name, an.mime_type) || '.mp4'
|
|
427
|
+
if ((an.file_size || 0) > maxDocBytes) {
|
|
428
|
+
text = `${text}\n[Animation too large]`.trim()
|
|
429
|
+
} else {
|
|
430
|
+
const { path } = await this.downloadByFileId(an.file_id, 'anim', ext, an.file_name || '')
|
|
431
|
+
attachments.push({ kind: 'animation', path, mime: an.mime_type || 'video/mp4', name: an.file_name || basename(path) })
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (msg.document) {
|
|
435
|
+
const doc = msg.document
|
|
436
|
+
const ext = extOf(doc.file_name, doc.mime_type)
|
|
437
|
+
const kind = classifyDocument(ext, doc.mime_type)
|
|
438
|
+
if (doc.file_size > maxDocBytes) {
|
|
439
|
+
text = `${text}\n[Document too large: ${doc.file_name || 'file'}]`.trim()
|
|
440
|
+
} else if (kind === 'unsupported') {
|
|
441
|
+
const { path } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
|
|
442
|
+
attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
|
|
443
|
+
} else {
|
|
444
|
+
const { path, bytes } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
|
|
445
|
+
if (kind === 'image') attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || doc.mime_type || 'image/jpeg', name: doc.file_name })
|
|
446
|
+
else if (kind === 'video') attachments.push({ kind: 'video', path, mime: VIDEO_EXT_TO_MIME[ext] || doc.mime_type || 'video/mp4', name: doc.file_name })
|
|
447
|
+
else if (bytes.length <= maxTextInjectBytes && TEXT_INJECT_EXTS.has(ext)) {
|
|
448
|
+
const body = new TextDecoder('utf-8', { fatal: false }).decode(bytes).slice(0, maxTextInjectBytes)
|
|
449
|
+
text = `${text}\n\n[Document ${doc.file_name}]\n${body}`.trim()
|
|
450
|
+
} else attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const messageId = msg.message_id
|
|
455
|
+
const reply = async (payload) => this.sendReply(chatId, messageId, payload, threadId)
|
|
456
|
+
const typing = async () => { try { await this.call('sendChatAction', { chat_id: chatId, action: 'typing', ...telegramThreadParams(threadId) }) } catch {} }
|
|
457
|
+
const startStream = async () => this.startStreamMessage(chatId, messageId, threadId)
|
|
458
|
+
const startProgress = async () => this.startProgressMessage(chatId, messageId, threadId)
|
|
459
|
+
const react = async (emoji) => this.setReaction(chatId, messageId, emoji)
|
|
460
|
+
|
|
461
|
+
await this.onMessage({
|
|
462
|
+
platform: 'telegram', chatId, userId, threadId, chatType, text, attachments, replyText,
|
|
463
|
+
messageId, reply, typing, startStream, startProgress, react,
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async startProgressMessage(chatId, replyTo, threadId = 0) {
|
|
468
|
+
const result = await this.call('sendMessage', {
|
|
469
|
+
chat_id: chatId,
|
|
470
|
+
text: '⏳ Думаю…',
|
|
471
|
+
reply_to_message_id: replyTo,
|
|
472
|
+
...telegramThreadParams(threadId),
|
|
473
|
+
})
|
|
474
|
+
const messageId = result?.message_id
|
|
475
|
+
return {
|
|
476
|
+
messageId,
|
|
477
|
+
edit: async (text) => {
|
|
478
|
+
const plain = String(text || '⏳ Думаю…').slice(0, TELEGRAM_MAX)
|
|
479
|
+
try {
|
|
480
|
+
await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '⏳ Думаю…' })
|
|
481
|
+
} catch (err) {
|
|
482
|
+
const msg = String(err.message || '')
|
|
483
|
+
if (!msg.includes('message is not modified')) this.logger?.warn?.(`progress edit: ${err.message}`)
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
remove: async () => {
|
|
487
|
+
try { await this.call('deleteMessage', { chat_id: chatId, message_id: messageId }) } catch {}
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async startStreamMessage(chatId, replyTo, threadId = 0) {
|
|
493
|
+
let result
|
|
494
|
+
try {
|
|
495
|
+
result = await this.call('sendMessage', {
|
|
496
|
+
chat_id: chatId,
|
|
497
|
+
text: '…',
|
|
498
|
+
reply_to_message_id: replyTo,
|
|
499
|
+
...telegramThreadParams(threadId),
|
|
500
|
+
})
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if (threadId && isTopicGoneError(err)) {
|
|
503
|
+
this.logger?.warn?.(`telegram stream start topic gone (thread ${threadId}), fallback main chat: ${err.message}`)
|
|
504
|
+
result = await this.call('sendMessage', {
|
|
505
|
+
chat_id: chatId,
|
|
506
|
+
text: '…',
|
|
507
|
+
reply_to_message_id: replyTo,
|
|
508
|
+
})
|
|
509
|
+
} else {
|
|
510
|
+
throw err
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const messageId = result?.message_id
|
|
514
|
+
return {
|
|
515
|
+
messageId,
|
|
516
|
+
edit: async (text) => {
|
|
517
|
+
const plain = String(text || '…').slice(0, TELEGRAM_MAX)
|
|
518
|
+
try {
|
|
519
|
+
await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '…' })
|
|
520
|
+
} catch (err) {
|
|
521
|
+
const msg = String(err.message || '')
|
|
522
|
+
if (!msg.includes('message is not modified')) throw err
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
finalize: async (text, payload = {}) => {
|
|
526
|
+
const { text: formatted, parseMode } = this.formatOutgoingText(String(text || ''), payload)
|
|
527
|
+
const chunk = splitText(formatted, TELEGRAM_MAX)[0] || '…'
|
|
528
|
+
const params = { chat_id: chatId, message_id: messageId, text: chunk }
|
|
529
|
+
if (parseMode) params.parse_mode = parseMode
|
|
530
|
+
try {
|
|
531
|
+
await this.call('editMessageText', params)
|
|
532
|
+
} catch (err) {
|
|
533
|
+
if (!parseMode) {
|
|
534
|
+
const msg = String(err.message || '')
|
|
535
|
+
if (!msg.includes('message is not modified')) throw err
|
|
536
|
+
return
|
|
537
|
+
}
|
|
538
|
+
await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: splitText(String(text || ''), TELEGRAM_MAX)[0] || '…' })
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async sendMedia(chatId, file, threadId = 0) {
|
|
545
|
+
const form = new FormData()
|
|
546
|
+
form.append('chat_id', String(chatId))
|
|
547
|
+
const thread = telegramThreadParams(threadId)
|
|
548
|
+
if (thread.message_thread_id) form.append('message_thread_id', String(thread.message_thread_id))
|
|
549
|
+
const blob = new Blob([file.bytes])
|
|
550
|
+
const name = safeName(file.name || 'file')
|
|
551
|
+
const isSvg = file.mime === 'image/svg+xml' || (file.name && file.name.toLowerCase().endsWith('.svg'))
|
|
552
|
+
const send = (m, f) => this.sendWithRetry(m, f, { multipart: true })
|
|
553
|
+
const method = (file.kind === 'photo' && !isSvg) ? 'sendPhoto'
|
|
554
|
+
: (file.kind === 'voice') ? 'sendVoice'
|
|
555
|
+
: (file.kind === 'audio') ? 'sendAudio'
|
|
556
|
+
: (file.kind === 'video') ? 'sendVideo'
|
|
557
|
+
: 'sendDocument'
|
|
558
|
+
const fieldName = (file.kind === 'photo' && !isSvg) ? 'photo'
|
|
559
|
+
: (file.kind === 'voice') ? 'voice'
|
|
560
|
+
: (file.kind === 'audio') ? 'audio'
|
|
561
|
+
: (file.kind === 'video') ? 'video'
|
|
562
|
+
: 'document'
|
|
563
|
+
form.append(fieldName, blob, name)
|
|
564
|
+
try {
|
|
565
|
+
return await send(method, form)
|
|
566
|
+
} catch (err) {
|
|
567
|
+
if (threadId && isTopicGoneError(err)) {
|
|
568
|
+
this.logger?.warn?.(`telegram sendMedia topic gone (thread ${threadId}), fallback main chat: ${err.message}`)
|
|
569
|
+
const fallbackForm = new FormData()
|
|
570
|
+
fallbackForm.append('chat_id', String(chatId))
|
|
571
|
+
fallbackForm.append(fieldName, blob, name)
|
|
572
|
+
return await send(method, fallbackForm)
|
|
573
|
+
}
|
|
574
|
+
throw err
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
formatOutgoingText(text, payload = {}) {
|
|
579
|
+
const mode = payload.parseMode === 'HTML' ? 'html'
|
|
580
|
+
: payload.parseMode === 'plain' ? 'plain'
|
|
581
|
+
: this.textFormat
|
|
582
|
+
return prepareTelegramText(text, mode)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async sendFormattedMessage(chatId, replyTo, text, payload, threadId, replyMarkup) {
|
|
586
|
+
const { text: formatted, parseMode } = this.formatOutgoingText(text, payload)
|
|
587
|
+
const chunks = splitText(formatted, TELEGRAM_MAX)
|
|
588
|
+
const plainChunks = splitText(text, TELEGRAM_MAX)
|
|
589
|
+
const effectiveMarkup = replyMarkup !== undefined
|
|
590
|
+
? replyMarkup
|
|
591
|
+
: (this.quickActions && !threadId
|
|
592
|
+
? buildQuickActionsKeyboard()
|
|
593
|
+
: (!threadId ? REMOVE_REPLY_KEYBOARD : undefined))
|
|
594
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
595
|
+
const params = {
|
|
596
|
+
chat_id: chatId,
|
|
597
|
+
text: chunks[i],
|
|
598
|
+
reply_to_message_id: replyTo,
|
|
599
|
+
reply_markup: i === 0 ? effectiveMarkup : undefined,
|
|
600
|
+
...telegramThreadParams(threadId),
|
|
601
|
+
}
|
|
602
|
+
if (parseMode) params.parse_mode = parseMode
|
|
603
|
+
try {
|
|
604
|
+
await this.sendWithRetry('sendMessage', params)
|
|
605
|
+
} catch (err) {
|
|
606
|
+
if (threadId && isTopicGoneError(err)) {
|
|
607
|
+
this.logger?.warn?.(`telegram send topic gone (thread ${threadId}), fallback main chat: ${err.message}`)
|
|
608
|
+
params.message_thread_id = undefined
|
|
609
|
+
delete params.message_thread_id
|
|
610
|
+
await this.sendWithRetry('sendMessage', params)
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
if (!parseMode) throw err
|
|
614
|
+
this.logger?.warn?.(`telegram HTML send failed, fallback plain: ${err.message}`)
|
|
615
|
+
try {
|
|
616
|
+
await this.call('sendMessage', {
|
|
617
|
+
chat_id: chatId,
|
|
618
|
+
text: plainChunks[i] ?? chunks[i],
|
|
619
|
+
reply_to_message_id: replyTo,
|
|
620
|
+
reply_markup: i === 0 ? effectiveMarkup : undefined,
|
|
621
|
+
...telegramThreadParams(threadId),
|
|
622
|
+
})
|
|
623
|
+
} catch (err2) {
|
|
624
|
+
if (threadId && isTopicGoneError(err2)) {
|
|
625
|
+
this.logger?.warn?.(`telegram plain send topic gone (thread ${threadId}), fallback main chat: ${err2.message}`)
|
|
626
|
+
await this.call('sendMessage', {
|
|
627
|
+
chat_id: chatId,
|
|
628
|
+
text: plainChunks[i] ?? chunks[i],
|
|
629
|
+
reply_to_message_id: replyTo,
|
|
630
|
+
reply_markup: i === 0 ? effectiveMarkup : undefined,
|
|
631
|
+
})
|
|
632
|
+
} else {
|
|
633
|
+
throw err2
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
async sendReply(chatId, replyTo, payload, threadId = 0) {
|
|
642
|
+
const body = typeof payload === 'string' ? { text: payload } : (payload || {})
|
|
643
|
+
const files = Array.isArray(body.files) ? body.files : []
|
|
644
|
+
const text = String(body.text || '')
|
|
645
|
+
const replyMarkup = body.replyMarkup
|
|
646
|
+
for (const file of files) {
|
|
647
|
+
try {
|
|
648
|
+
const bytes = file.bytes || (file.path ? await readFile(file.path) : null)
|
|
649
|
+
if (!bytes) continue
|
|
650
|
+
await this.sendMedia(chatId, { ...file, bytes }, threadId)
|
|
651
|
+
} catch (e) { this.logger?.warn?.(`send media: ${e.message}`) }
|
|
652
|
+
}
|
|
653
|
+
if (text) await this.sendFormattedMessage(chatId, replyTo, text, body, threadId, replyMarkup)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async sendTo(chatId, payload, opts = {}) {
|
|
657
|
+
return this.sendReply(chatId, undefined, payload, normalizeThreadId(opts.threadId))
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async probeHealth(timeoutMs = 10000) {
|
|
661
|
+
if (!this.token) {
|
|
662
|
+
return { ok: false, error: 'Telegram bot token is empty' }
|
|
663
|
+
}
|
|
664
|
+
const start = Date.now()
|
|
665
|
+
try {
|
|
666
|
+
const res = await fetch(`${API}/bot${this.token}/getMe`, {
|
|
667
|
+
method: 'POST',
|
|
668
|
+
headers: { 'Content-Type': 'application/json' },
|
|
669
|
+
body: JSON.stringify({}),
|
|
670
|
+
signal: AbortSignal.timeout(Math.max(1000, Number(timeoutMs) || 10000)),
|
|
671
|
+
})
|
|
672
|
+
const latencyMs = Date.now() - start
|
|
673
|
+
const json = await res.json().catch(() => ({}))
|
|
674
|
+
if (!res.ok || json.ok === false) {
|
|
675
|
+
return {
|
|
676
|
+
ok: false,
|
|
677
|
+
latencyMs,
|
|
678
|
+
error: json.description || `HTTP ${res.status}`,
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const me = json.result || {}
|
|
682
|
+
this.botId = Number(me.id) || this.botId
|
|
683
|
+
this.botUsername = String(me.username || this.botUsername)
|
|
684
|
+
return {
|
|
685
|
+
ok: true,
|
|
686
|
+
latencyMs,
|
|
687
|
+
botId: this.botId,
|
|
688
|
+
botUsername: this.botUsername,
|
|
689
|
+
firstName: me.first_name || '',
|
|
690
|
+
}
|
|
691
|
+
} catch (err) {
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
latencyMs: Date.now() - start,
|
|
695
|
+
error: err instanceof Error ? err.message : String(err),
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async createForumTopic(chatId, name, opts = {}) {
|
|
701
|
+
return this.call('createForumTopic', {
|
|
702
|
+
chat_id: chatId,
|
|
703
|
+
name,
|
|
704
|
+
icon_color: opts.iconColor,
|
|
705
|
+
icon_custom_emoji_id: opts.iconCustomEmojiId,
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
}
|