@goodandready/dsh-messenger-gateway 0.3.21 → 0.3.22

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,708 +1,712 @@
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: 'Access denied',
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' : ''}[quote: ${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[Video sticker ${st.emoji}]`.trim()
381
- } catch (e) {
382
- text = `${text}\n[Video sticker ${st.emoji || ''} (download failed: ${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[Animated sticker ${st.emoji}]`.trim()
389
- } catch (e) {
390
- text = `${text}\n[Animated sticker ${st.emoji || ''} (download failed: ${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: '⏳ Thinking…',
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 || '⏳ Thinking…').slice(0, TELEGRAM_MAX)
479
- try {
480
- await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '⏳ Thinking…' })
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
- }
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
+ let backoffDelay
216
+ try {
217
+ const updates = await this.call('getUpdates', {
218
+ timeout: this.timeoutSeconds,
219
+ offset: this.offset,
220
+ allowed_updates: ['message', 'callback_query'],
221
+ })
222
+ this.pollingConflict = false
223
+ this.pollErrorCount = 0
224
+ for (const update of updates || []) {
225
+ this.offset = Math.max(this.offset, update.update_id + 1)
226
+ await this.dispatchUpdate(update)
227
+ }
228
+ } catch (e) {
229
+ if (!this.stopped) {
230
+ this.pollErrorCount = (this.pollErrorCount || 0) + 1
231
+ if (isPollingConflict(e)) {
232
+ this.pollingConflict = true
233
+ backoffDelay = 15_000
234
+ this.logger?.error?.(`poll: TELEGRAM CONFLICT — another bot instance is polling the same token. Stop the duplicate instance. (${e.message})`)
235
+ } else {
236
+ backoffDelay = computePollBackoffMs(this.pollIntervalMs, this.pollErrorCount)
237
+ this.logger?.warn?.(`poll: ${e.message} (retrying in ${backoffDelay}ms, error #${this.pollErrorCount})`)
238
+ }
239
+ }
240
+ }
241
+ this.schedulePoll(backoffDelay)
242
+ }
243
+
244
+ async dispatchUpdate(update) {
245
+ if (update.callback_query) {
246
+ const fromId = update.callback_query.from?.id
247
+ if (fromId && !this.allowed(fromId)) {
248
+ await this.call('answerCallbackQuery', {
249
+ callback_query_id: update.callback_query.id,
250
+ text: 'Access denied',
251
+ show_alert: true,
252
+ }).catch(() => {})
253
+ return
254
+ }
255
+ try { await this.onCallback?.(this.wrapCallback(update.callback_query)) } catch (e) {
256
+ this.logger?.warn?.(`callback: ${e.message}`)
257
+ }
258
+ return
259
+ }
260
+ const msg = update.message
261
+ if (!msg) return
262
+ try { await this.handleMessage(msg) } catch (e) {
263
+ this.logger?.warn?.(`message: ${e.message}`)
264
+ }
265
+ }
266
+
267
+ /** HTTP webhook entry (caller verifies secret). */
268
+ async handleWebhookUpdate(update) {
269
+ if (this.stopped) return
270
+ await this.dispatchUpdate(update)
271
+ }
272
+
273
+ wrapCallback(cq) {
274
+ const chatId = cq.message?.chat?.id
275
+ const messageId = cq.message?.message_id
276
+ return {
277
+ platform: 'telegram', chatId, threadId: cq.message?.message_thread_id || 0, userId: cq.from?.id, data: cq.data, callbackQueryId: cq.id,
278
+ message: cq.message,
279
+ answer: async (text) => this.call('answerCallbackQuery', { callback_query_id: cq.id, text: text || '' }),
280
+ editMessage: async (text, replyMarkup) => {
281
+ const { text: formatted, parseMode } = this.formatOutgoingText(text)
282
+ const params = { chat_id: chatId, message_id: messageId, text: formatted, reply_markup: replyMarkup }
283
+ if (parseMode) params.parse_mode = parseMode
284
+ try {
285
+ return await this.call('editMessageText', params)
286
+ } catch (err) {
287
+ if (!parseMode) throw err
288
+ return this.call('editMessageText', { chat_id: chatId, message_id: messageId, text, reply_markup: replyMarkup })
289
+ }
290
+ },
291
+ editReplyMarkup: async (replyMarkup) => {
292
+ return this.call('editMessageReplyMarkup', {
293
+ chat_id: chatId,
294
+ message_id: messageId,
295
+ reply_markup: replyMarkup,
296
+ })
297
+ },
298
+ }
299
+ }
300
+
301
+ allowed(userId) {
302
+ if (typeof this.isUserAllowed === 'function') return this.isUserAllowed(userId)
303
+ return this.allowedUserIds.length === 0 || this.allowedUserIds.includes(Number(userId))
304
+ }
305
+
306
+ async downloadByFileId(fileId, prefix, ext, name = '') {
307
+ const file = await this.getFile(fileId)
308
+ const bytes = await this.downloadFile(file.file_path)
309
+ const resolvedExt = ext || extOf(file.file_path, '') || ''
310
+ const path = saveToCache(this.media.cacheDir, cacheName(prefix, resolvedExt, name), bytes)
311
+ return { path, bytes, file }
312
+ }
313
+
314
+ async setReaction(chatId, messageId, emoji) {
315
+ if (!this.reactionsEnabled || !messageId) return
316
+ try {
317
+ await this.call('setMessageReaction', {
318
+ chat_id: chatId,
319
+ message_id: messageId,
320
+ reaction: emoji ? [{ type: 'emoji', emoji }] : [],
321
+ })
322
+ } catch (e) {
323
+ this.logger?.warn?.(`reaction: ${e.message}`)
324
+ }
325
+ }
326
+
327
+ async handleMessage(msg) {
328
+ const chatId = msg.chat.id
329
+ const chatType = msg.chat?.type || 'private'
330
+ const userId = msg.from?.id ?? chatId
331
+ let text = msg.text ?? msg.caption ?? ''
332
+ const entities = msg.entities || msg.caption_entities || []
333
+ const gate = shouldProcessTelegramMessage({
334
+ chatType,
335
+ text,
336
+ entities,
337
+ replyTo: msg.reply_to_message,
338
+ botId: this.botId,
339
+ botUsername: this.botUsername,
340
+ groupsEnabled: this.groupsEnabled,
341
+ requireMention: this.groupRequireMention,
342
+ })
343
+ if (!gate.ok) return
344
+
345
+ if (!this.allowed(userId)) {
346
+ if (chatType !== 'private') return
347
+ if (this.onUnauthorized) {
348
+ await this.onUnauthorized({
349
+ platform: 'telegram', chatId, userId, threadId: msg.message_thread_id || 0,
350
+ username: msg.from?.username || '',
351
+ reply: async (payload) => this.sendReply(chatId, msg.message_id, payload, msg.message_thread_id || 0),
352
+ })
353
+ }
354
+ return
355
+ }
356
+
357
+ text = stripBotCommandSuffix(text, this.botUsername)
358
+ const threadId = msg.message_thread_id || 0
359
+ const maxDocBytes = this.media.maxDocBytes ?? 20 * 1024 * 1024
360
+ const maxTextInjectBytes = this.media.maxTextInjectBytes ?? 100 * 1024
361
+ const attachments = []
362
+ const replyMsg = msg.reply_to_message
363
+ let replyText = ''
364
+ if (replyMsg) {
365
+ const quoted = replyMsg.text ?? replyMsg.caption ?? ''
366
+ const quoteFrag = msg.quote?.text || ''
367
+ replyText = quoteFrag
368
+ ? `${quoted}${quoted ? '\n' : ''}[quote: ${quoteFrag}]`
369
+ : quoted
370
+ }
371
+
372
+ if (msg.photo?.length) {
373
+ const largest = msg.photo[msg.photo.length - 1]
374
+ const { path, file } = await this.downloadByFileId(largest.file_id, 'photo', extOf('', '') || '.jpg')
375
+ const ext = extOf(file.file_path, '') || '.jpg'
376
+ attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || 'image/jpeg' })
377
+ }
378
+ if (msg.sticker) {
379
+ const st = msg.sticker
380
+ if (st.is_video) {
381
+ try {
382
+ const { path } = await this.downloadByFileId(st.file_id, 'sticker-video', '.webm', st.file_unique_id || '')
383
+ attachments.push({ kind: 'animation', path, mime: 'video/webm', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webm` })
384
+ if (st.emoji) text = `${text}\n[Video sticker ${st.emoji}]`.trim()
385
+ } catch (e) {
386
+ text = `${text}\n[Video sticker ${st.emoji || ''} (download failed: ${e.message})]`.trim()
387
+ }
388
+ } else if (st.is_animated) {
389
+ try {
390
+ const { path } = await this.downloadByFileId(st.file_id, 'sticker-anim', '.tgs', st.file_unique_id || '')
391
+ attachments.push({ kind: 'document', path, mime: 'application/x-tgsticker', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.tgs` })
392
+ if (st.emoji) text = `${text}\n[Animated sticker ${st.emoji}]`.trim()
393
+ } catch (e) {
394
+ text = `${text}\n[Animated sticker ${st.emoji || ''} (download failed: ${e.message})]`.trim()
395
+ }
396
+ } else {
397
+ const { path } = await this.downloadByFileId(st.file_id, 'sticker', '.webp', st.file_unique_id || '')
398
+ attachments.push({ kind: 'sticker', path, mime: 'image/webp', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webp` })
399
+ }
400
+ }
401
+ if (msg.voice) {
402
+ const { path } = await this.downloadByFileId(msg.voice.file_id, 'voice', '.ogg')
403
+ attachments.push({ kind: 'voice', path, mime: 'audio/ogg' })
404
+ }
405
+ if (msg.audio) {
406
+ const ext = extOf(msg.audio.file_name, msg.audio.mime_type) || '.mp3'
407
+ const { path } = await this.downloadByFileId(msg.audio.file_id, 'audio', ext, msg.audio.file_name || '')
408
+ attachments.push({ kind: 'audio', path, mime: msg.audio.mime_type || 'audio/mpeg' })
409
+ }
410
+ if (msg.video) {
411
+ const ext = extOf(msg.video.file_name, msg.video.mime_type) || '.mp4'
412
+ if ((msg.video.file_size || 0) > maxDocBytes) {
413
+ text = `${text}\n[Video too large]`.trim()
414
+ } else {
415
+ const { path } = await this.downloadByFileId(msg.video.file_id, 'video', ext, msg.video.file_name || '')
416
+ attachments.push({ kind: 'video', path, mime: msg.video.mime_type || VIDEO_EXT_TO_MIME[ext] || 'video/mp4', name: msg.video.file_name || basename(path) })
417
+ }
418
+ }
419
+ if (msg.video_note) {
420
+ const vn = msg.video_note
421
+ if ((vn.file_size || 0) > maxDocBytes) {
422
+ text = `${text}\n[Video note too large]`.trim()
423
+ } else {
424
+ const { path } = await this.downloadByFileId(vn.file_id, 'videonote', '.mp4')
425
+ attachments.push({ kind: 'video', path, mime: 'video/mp4', name: 'video_note.mp4' })
426
+ }
427
+ }
428
+ if (msg.animation) {
429
+ const an = msg.animation
430
+ const ext = extOf(an.file_name, an.mime_type) || '.mp4'
431
+ if ((an.file_size || 0) > maxDocBytes) {
432
+ text = `${text}\n[Animation too large]`.trim()
433
+ } else {
434
+ const { path } = await this.downloadByFileId(an.file_id, 'anim', ext, an.file_name || '')
435
+ attachments.push({ kind: 'animation', path, mime: an.mime_type || 'video/mp4', name: an.file_name || basename(path) })
436
+ }
437
+ }
438
+ if (msg.document) {
439
+ const doc = msg.document
440
+ const ext = extOf(doc.file_name, doc.mime_type)
441
+ const kind = classifyDocument(ext, doc.mime_type)
442
+ if (doc.file_size > maxDocBytes) {
443
+ text = `${text}\n[Document too large: ${doc.file_name || 'file'}]`.trim()
444
+ } else if (kind === 'unsupported') {
445
+ const { path } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
446
+ attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
447
+ } else {
448
+ const { path, bytes } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
449
+ if (kind === 'image') attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || doc.mime_type || 'image/jpeg', name: doc.file_name })
450
+ else if (kind === 'video') attachments.push({ kind: 'video', path, mime: VIDEO_EXT_TO_MIME[ext] || doc.mime_type || 'video/mp4', name: doc.file_name })
451
+ else if (bytes.length <= maxTextInjectBytes && TEXT_INJECT_EXTS.has(ext)) {
452
+ const body = new TextDecoder('utf-8', { fatal: false }).decode(bytes).slice(0, maxTextInjectBytes)
453
+ text = `${text}\n\n[Document ${doc.file_name}]\n${body}`.trim()
454
+ } else attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
455
+ }
456
+ }
457
+
458
+ const messageId = msg.message_id
459
+ const reply = async (payload) => this.sendReply(chatId, messageId, payload, threadId)
460
+ const typing = async () => { try { await this.call('sendChatAction', { chat_id: chatId, action: 'typing', ...telegramThreadParams(threadId) }) } catch {} }
461
+ const startStream = async () => this.startStreamMessage(chatId, messageId, threadId)
462
+ const startProgress = async () => this.startProgressMessage(chatId, messageId, threadId)
463
+ const react = async (emoji) => this.setReaction(chatId, messageId, emoji)
464
+
465
+ await this.onMessage({
466
+ platform: 'telegram', chatId, userId, threadId, chatType, text, attachments, replyText,
467
+ messageId, reply, typing, startStream, startProgress, react,
468
+ })
469
+ }
470
+
471
+ async startProgressMessage(chatId, replyTo, threadId = 0) {
472
+ const result = await this.call('sendMessage', {
473
+ chat_id: chatId,
474
+ text: '⏳ Thinking…',
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 || '⏳ Thinking…').slice(0, TELEGRAM_MAX)
483
+ try {
484
+ await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '⏳ Thinking…' })
485
+ } catch (err) {
486
+ const msg = String(err.message || '')
487
+ if (!msg.includes('message is not modified')) this.logger?.warn?.(`progress edit: ${err.message}`)
488
+ }
489
+ },
490
+ remove: async () => {
491
+ try { await this.call('deleteMessage', { chat_id: chatId, message_id: messageId }) } catch {}
492
+ },
493
+ }
494
+ }
495
+
496
+ async startStreamMessage(chatId, replyTo, threadId = 0) {
497
+ let result
498
+ try {
499
+ result = await this.call('sendMessage', {
500
+ chat_id: chatId,
501
+ text: '…',
502
+ reply_to_message_id: replyTo,
503
+ ...telegramThreadParams(threadId),
504
+ })
505
+ } catch (err) {
506
+ if (threadId && isTopicGoneError(err)) {
507
+ this.logger?.warn?.(`telegram stream start topic gone (thread ${threadId}), fallback main chat: ${err.message}`)
508
+ result = await this.call('sendMessage', {
509
+ chat_id: chatId,
510
+ text: '…',
511
+ reply_to_message_id: replyTo,
512
+ })
513
+ } else {
514
+ throw err
515
+ }
516
+ }
517
+ const messageId = result?.message_id
518
+ return {
519
+ messageId,
520
+ edit: async (text) => {
521
+ const plain = String(text || '').slice(0, TELEGRAM_MAX)
522
+ try {
523
+ await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '…' })
524
+ } catch (err) {
525
+ const msg = String(err.message || '')
526
+ if (!msg.includes('message is not modified')) throw err
527
+ }
528
+ },
529
+ finalize: async (text, payload = {}) => {
530
+ const { text: formatted, parseMode } = this.formatOutgoingText(String(text || ''), payload)
531
+ const chunk = splitText(formatted, TELEGRAM_MAX)[0] || '…'
532
+ const params = { chat_id: chatId, message_id: messageId, text: chunk }
533
+ if (parseMode) params.parse_mode = parseMode
534
+ try {
535
+ await this.call('editMessageText', params)
536
+ } catch (err) {
537
+ if (!parseMode) {
538
+ const msg = String(err.message || '')
539
+ if (!msg.includes('message is not modified')) throw err
540
+ return
541
+ }
542
+ await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: splitText(String(text || ''), TELEGRAM_MAX)[0] || '…' })
543
+ }
544
+ },
545
+ }
546
+ }
547
+
548
+ async sendMedia(chatId, file, threadId = 0) {
549
+ const form = new FormData()
550
+ form.append('chat_id', String(chatId))
551
+ const thread = telegramThreadParams(threadId)
552
+ if (thread.message_thread_id) form.append('message_thread_id', String(thread.message_thread_id))
553
+ const blob = new Blob([file.bytes])
554
+ const name = safeName(file.name || 'file')
555
+ const isSvg = file.mime === 'image/svg+xml' || (file.name && file.name.toLowerCase().endsWith('.svg'))
556
+ const send = (m, f) => this.sendWithRetry(m, f, { multipart: true })
557
+ const method = (file.kind === 'photo' && !isSvg) ? 'sendPhoto'
558
+ : (file.kind === 'voice') ? 'sendVoice'
559
+ : (file.kind === 'audio') ? 'sendAudio'
560
+ : (file.kind === 'video') ? 'sendVideo'
561
+ : 'sendDocument'
562
+ const fieldName = (file.kind === 'photo' && !isSvg) ? 'photo'
563
+ : (file.kind === 'voice') ? 'voice'
564
+ : (file.kind === 'audio') ? 'audio'
565
+ : (file.kind === 'video') ? 'video'
566
+ : 'document'
567
+ form.append(fieldName, blob, name)
568
+ try {
569
+ return await send(method, form)
570
+ } catch (err) {
571
+ if (threadId && isTopicGoneError(err)) {
572
+ this.logger?.warn?.(`telegram sendMedia topic gone (thread ${threadId}), fallback main chat: ${err.message}`)
573
+ const fallbackForm = new FormData()
574
+ fallbackForm.append('chat_id', String(chatId))
575
+ fallbackForm.append(fieldName, blob, name)
576
+ return await send(method, fallbackForm)
577
+ }
578
+ throw err
579
+ }
580
+ }
581
+
582
+ formatOutgoingText(text, payload = {}) {
583
+ const mode = payload.parseMode === 'HTML' ? 'html'
584
+ : payload.parseMode === 'plain' ? 'plain'
585
+ : this.textFormat
586
+ return prepareTelegramText(text, mode)
587
+ }
588
+
589
+ async sendFormattedMessage(chatId, replyTo, text, payload, threadId, replyMarkup) {
590
+ const { text: formatted, parseMode } = this.formatOutgoingText(text, payload)
591
+ const chunks = splitText(formatted, TELEGRAM_MAX)
592
+ const plainChunks = splitText(text, TELEGRAM_MAX)
593
+ const effectiveMarkup = replyMarkup !== undefined
594
+ ? replyMarkup
595
+ : (this.quickActions && !threadId
596
+ ? buildQuickActionsKeyboard()
597
+ : (!threadId ? REMOVE_REPLY_KEYBOARD : undefined))
598
+ for (let i = 0; i < chunks.length; i++) {
599
+ const params = {
600
+ chat_id: chatId,
601
+ text: chunks[i],
602
+ reply_to_message_id: replyTo,
603
+ reply_markup: i === 0 ? effectiveMarkup : undefined,
604
+ ...telegramThreadParams(threadId),
605
+ }
606
+ if (parseMode) params.parse_mode = parseMode
607
+ try {
608
+ await this.sendWithRetry('sendMessage', params)
609
+ } catch (err) {
610
+ if (threadId && isTopicGoneError(err)) {
611
+ this.logger?.warn?.(`telegram send topic gone (thread ${threadId}), fallback main chat: ${err.message}`)
612
+ params.message_thread_id = undefined
613
+ delete params.message_thread_id
614
+ await this.sendWithRetry('sendMessage', params)
615
+ continue
616
+ }
617
+ if (!parseMode) throw err
618
+ this.logger?.warn?.(`telegram HTML send failed, fallback plain: ${err.message}`)
619
+ try {
620
+ await this.call('sendMessage', {
621
+ chat_id: chatId,
622
+ text: plainChunks[i] ?? chunks[i],
623
+ reply_to_message_id: replyTo,
624
+ reply_markup: i === 0 ? effectiveMarkup : undefined,
625
+ ...telegramThreadParams(threadId),
626
+ })
627
+ } catch (err2) {
628
+ if (threadId && isTopicGoneError(err2)) {
629
+ this.logger?.warn?.(`telegram plain send topic gone (thread ${threadId}), fallback main chat: ${err2.message}`)
630
+ await this.call('sendMessage', {
631
+ chat_id: chatId,
632
+ text: plainChunks[i] ?? chunks[i],
633
+ reply_to_message_id: replyTo,
634
+ reply_markup: i === 0 ? effectiveMarkup : undefined,
635
+ })
636
+ } else {
637
+ throw err2
638
+ }
639
+ }
640
+ }
641
+ }
642
+ }
643
+
644
+
645
+ async sendReply(chatId, replyTo, payload, threadId = 0) {
646
+ const body = typeof payload === 'string' ? { text: payload } : (payload || {})
647
+ const files = Array.isArray(body.files) ? body.files : []
648
+ const text = String(body.text || '')
649
+ const replyMarkup = body.replyMarkup
650
+ for (const file of files) {
651
+ try {
652
+ const bytes = file.bytes || (file.path ? await readFile(file.path) : null)
653
+ if (!bytes) continue
654
+ await this.sendMedia(chatId, { ...file, bytes }, threadId)
655
+ } catch (e) { this.logger?.warn?.(`send media: ${e.message}`) }
656
+ }
657
+ if (text) await this.sendFormattedMessage(chatId, replyTo, text, body, threadId, replyMarkup)
658
+ }
659
+
660
+ async sendTo(chatId, payload, opts = {}) {
661
+ return this.sendReply(chatId, undefined, payload, normalizeThreadId(opts.threadId))
662
+ }
663
+
664
+ async probeHealth(timeoutMs = 10000) {
665
+ if (!this.token) {
666
+ return { ok: false, error: 'Telegram bot token is empty' }
667
+ }
668
+ const start = Date.now()
669
+ try {
670
+ const res = await fetch(`${API}/bot${this.token}/getMe`, {
671
+ method: 'POST',
672
+ headers: { 'Content-Type': 'application/json' },
673
+ body: JSON.stringify({}),
674
+ signal: AbortSignal.timeout(Math.max(1000, Number(timeoutMs) || 10000)),
675
+ })
676
+ const latencyMs = Date.now() - start
677
+ const json = await res.json().catch(() => ({}))
678
+ if (!res.ok || json.ok === false) {
679
+ return {
680
+ ok: false,
681
+ latencyMs,
682
+ error: json.description || `HTTP ${res.status}`,
683
+ }
684
+ }
685
+ const me = json.result || {}
686
+ this.botId = Number(me.id) || this.botId
687
+ this.botUsername = String(me.username || this.botUsername)
688
+ return {
689
+ ok: true,
690
+ latencyMs,
691
+ botId: this.botId,
692
+ botUsername: this.botUsername,
693
+ firstName: me.first_name || '',
694
+ }
695
+ } catch (err) {
696
+ return {
697
+ ok: false,
698
+ latencyMs: Date.now() - start,
699
+ error: err instanceof Error ? err.message : String(err),
700
+ }
701
+ }
702
+ }
703
+
704
+ async createForumTopic(chatId, name, opts = {}) {
705
+ return this.call('createForumTopic', {
706
+ chat_id: chatId,
707
+ name,
708
+ icon_color: opts.iconColor,
709
+ icon_custom_emoji_id: opts.iconCustomEmojiId,
710
+ })
711
+ }
712
+ }