@goodandready/dsh-messenger-gateway 0.3.22 → 0.4.0

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,712 +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,
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
- }
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
+ }