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