@goodandready/dsh-messenger-gateway 0.4.0 → 0.4.1

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