@goodandready/dsh-messenger-gateway 0.1.0 → 0.3.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,10 +1,17 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import {
3
3
  IMAGE_EXT_TO_MIME, VIDEO_EXT_TO_MIME, basename, cacheName, classifyDocument,
4
- extOf, mediaKindOf, saveToCache, safeName,
4
+ extOf, saveToCache, safeName,
5
5
  } from '../media.js'
6
+ import { TEXT_INJECT_EXTS } from '../documents.js'
6
7
  import { splitText } from '../text.js'
8
+ import { prepareTelegramText } from '../telegram-format.js'
9
+ import { normalizeTelegramCommands } from '../commands.js'
7
10
  import { normalizeThreadId, telegramThreadParams } from '../topics.js'
11
+ import {
12
+ shouldProcessTelegramMessage, stripBotCommandSuffix,
13
+ } from '../groups.js'
14
+ import { isResendSafeNetworkError, isPollingConflict } from '../telegram-errors.js'
8
15
 
9
16
  const API = 'https://api.telegram.org'
10
17
  const TELEGRAM_MAX = 4096
@@ -19,10 +26,32 @@ export class TelegramAdapter {
19
26
  this.media = opts.media || {}
20
27
  this.onMessage = opts.onMessage
21
28
  this.onCallback = opts.onCallback
29
+ this.onUnauthorized = opts.onUnauthorized
30
+ this.isUserAllowed = opts.isUserAllowed
22
31
  this.logger = opts.logger
32
+ this.commands = normalizeTelegramCommands(opts.commands)
33
+ this.textFormat = opts.textFormat === 'plain' ? 'plain' : 'html'
34
+ this.groupsEnabled = opts.groupsEnabled !== false
35
+ this.groupRequireMention = opts.groupRequireMention !== false
36
+ this.reactionsEnabled = opts.reactionsEnabled !== false
37
+ this.transport = opts.transport === 'webhook' ? 'webhook' : 'poll'
38
+ this.statusIndicator = opts.statusIndicator === true
39
+ this.statusOnline = String(opts.statusOnline || 'Online')
40
+ this.statusOffline = String(opts.statusOffline || 'Offline')
41
+ this.sendRetryMax = 2
42
+ this.sendRetryBaseMs = 400
43
+ this.pollingConflict = false
44
+ this.webhookUrl = String(opts.webhookUrl || '').trim()
45
+ this.webhookSecret = String(opts.webhookSecret || '').trim()
23
46
  this.offset = 0
24
47
  this.stopped = false
25
48
  this.pollTimer = undefined
49
+ this.botId = 0
50
+ this.botUsername = ''
51
+ }
52
+
53
+ setAllowedUserIds(ids) {
54
+ this.allowedUserIds = (ids || []).map(Number).filter((n) => Number.isFinite(n))
26
55
  }
27
56
 
28
57
  async call(method, params = {}) {
@@ -41,6 +70,24 @@ export class TelegramAdapter {
41
70
  return json.result
42
71
  }
43
72
 
73
+ // Retry a send only on resend-safe network errors (request never reached Telegram).
74
+ // Permanent errors (4xx/5xx) and ambiguous timeouts are not retried to avoid duplicates.
75
+ async sendWithRetry(method, params, { multipart = false } = {}) {
76
+ const fn = () => (multipart ? this.callMultipart(method, params) : this.call(method, params))
77
+ let lastErr
78
+ for (let attempt = 0; attempt <= this.sendRetryMax; attempt++) {
79
+ try {
80
+ return await fn()
81
+ } catch (err) {
82
+ lastErr = err
83
+ if (!isResendSafeNetworkError(err) || attempt >= this.sendRetryMax) throw err
84
+ this.logger?.warn?.(`telegram ${method} resend-safe network error (attempt ${attempt + 1}/${this.sendRetryMax}), retrying: ${err.message}`)
85
+ await new Promise((r) => setTimeout(r, this.sendRetryBaseMs * (attempt + 1)))
86
+ }
87
+ }
88
+ throw lastErr
89
+ }
90
+
44
91
  async getFile(fileId) { return this.call('getFile', { file_id: fileId }) }
45
92
 
46
93
  async downloadFile(filePath) {
@@ -49,15 +96,63 @@ export class TelegramAdapter {
49
96
  return new Uint8Array(await res.arrayBuffer())
50
97
  }
51
98
 
99
+ async registerCommands() {
100
+ if (!this.commands.length) return
101
+ await this.call('setMyCommands', { commands: this.commands })
102
+ }
103
+
104
+ // Bots have no presence dot; the short description is the closest surface.
105
+ // Opt-in only — it mutates the bot's global profile visible to all users.
106
+ async setStatusIndicator(text) {
107
+ if (!this.statusIndicator) return
108
+ try {
109
+ await this.call('setMyShortDescription', { short_description: String(text || '').slice(0, 120) })
110
+ } catch (err) {
111
+ this.logger?.warn?.(`telegram setMyShortDescription: ${err.message}`)
112
+ }
113
+ }
114
+
52
115
  async start() {
53
116
  if (!this.token) throw new Error('telegram bot token is empty')
54
117
  this.stopped = false
118
+ try {
119
+ const me = await this.call('getMe')
120
+ this.botId = Number(me.id) || 0
121
+ this.botUsername = String(me.username || '')
122
+ this.logger?.info?.(`dsh-messenger-gateway: telegram bot @${this.botUsername} (${this.botId})`)
123
+ } catch (err) {
124
+ this.logger?.warn?.(`dsh-messenger-gateway: telegram getMe: ${err.message}`)
125
+ }
126
+ if (this.statusIndicator) await this.setStatusIndicator(this.statusOnline)
127
+ try {
128
+ await this.registerCommands()
129
+ this.logger?.info?.(`dsh-messenger-gateway: telegram commands registered (${this.commands.length})`)
130
+ } catch (err) {
131
+ this.logger?.warn?.(`dsh-messenger-gateway: telegram setMyCommands: ${err.message}`)
132
+ }
133
+ if (this.transport === 'webhook') {
134
+ if (!this.webhookUrl) throw new Error('telegram webhookUrl is required for webhook transport')
135
+ try {
136
+ await this.call('deleteWebhook', { drop_pending_updates: false })
137
+ } catch {}
138
+ const params = {
139
+ url: this.webhookUrl,
140
+ allowed_updates: ['message', 'callback_query'],
141
+ drop_pending_updates: false,
142
+ }
143
+ if (this.webhookSecret) params.secret_token = this.webhookSecret
144
+ await this.call('setWebhook', params)
145
+ this.logger?.info?.(`dsh-messenger-gateway: telegram webhook set → ${this.webhookUrl}`)
146
+ return
147
+ }
148
+ try { await this.call('deleteWebhook', { drop_pending_updates: false }) } catch {}
55
149
  this.poll()
56
150
  }
57
151
 
58
152
  stop() {
59
153
  this.stopped = true
60
154
  if (this.pollTimer) clearTimeout(this.pollTimer)
155
+ if (this.statusIndicator) this.setStatusIndicator(this.statusOffline).catch(() => {})
61
156
  }
62
157
 
63
158
  schedulePoll() {
@@ -74,71 +169,201 @@ export class TelegramAdapter {
74
169
  offset: this.offset,
75
170
  allowed_updates: ['message', 'callback_query'],
76
171
  })
172
+ this.pollingConflict = false
77
173
  for (const update of updates || []) {
78
174
  this.offset = Math.max(this.offset, update.update_id + 1)
79
- if (update.callback_query) {
80
- try { await this.onCallback?.(this.wrapCallback(update.callback_query)) } catch (e) {
81
- this.logger?.warn?.(`callback: ${e.message}`)
82
- }
83
- continue
84
- }
85
- const msg = update.message
86
- if (!msg) continue
87
- try { await this.handleMessage(msg) } catch (e) {
88
- this.logger?.warn?.(`message: ${e.message}`)
89
- }
175
+ await this.dispatchUpdate(update)
90
176
  }
91
177
  } catch (e) {
92
- if (!this.stopped) this.logger?.warn?.(`poll: ${e.message}`)
178
+ if (!this.stopped) {
179
+ if (isPollingConflict(e)) {
180
+ this.pollingConflict = true
181
+ this.logger?.error?.(`poll: TELEGRAM CONFLICT — another bot instance is polling the same token. Stop the duplicate instance. (${e.message})`)
182
+ } else {
183
+ this.logger?.warn?.(`poll: ${e.message}`)
184
+ }
185
+ }
93
186
  }
94
187
  this.schedulePoll()
95
188
  }
96
189
 
190
+ async dispatchUpdate(update) {
191
+ if (update.callback_query) {
192
+ try { await this.onCallback?.(this.wrapCallback(update.callback_query)) } catch (e) {
193
+ this.logger?.warn?.(`callback: ${e.message}`)
194
+ }
195
+ return
196
+ }
197
+ const msg = update.message
198
+ if (!msg) return
199
+ try { await this.handleMessage(msg) } catch (e) {
200
+ this.logger?.warn?.(`message: ${e.message}`)
201
+ }
202
+ }
203
+
204
+ /** HTTP webhook entry (caller verifies secret). */
205
+ async handleWebhookUpdate(update) {
206
+ if (this.stopped) return
207
+ await this.dispatchUpdate(update)
208
+ }
209
+
97
210
  wrapCallback(cq) {
98
211
  const chatId = cq.message?.chat?.id
99
212
  const messageId = cq.message?.message_id
100
213
  return {
101
214
  platform: 'telegram', chatId, threadId: cq.message?.message_thread_id || 0, userId: cq.from?.id, data: cq.data, callbackQueryId: cq.id,
215
+ message: cq.message,
102
216
  answer: async (text) => this.call('answerCallbackQuery', { callback_query_id: cq.id, text: text || '' }),
103
- editMessage: async (text, replyMarkup) => this.call('editMessageText', {
104
- chat_id: chatId, message_id: messageId, text, reply_markup: replyMarkup,
105
- }),
217
+ editMessage: async (text, replyMarkup) => {
218
+ const { text: formatted, parseMode } = this.formatOutgoingText(text)
219
+ const params = { chat_id: chatId, message_id: messageId, text: formatted, reply_markup: replyMarkup }
220
+ if (parseMode) params.parse_mode = parseMode
221
+ try {
222
+ return await this.call('editMessageText', params)
223
+ } catch (err) {
224
+ if (!parseMode) throw err
225
+ return this.call('editMessageText', { chat_id: chatId, message_id: messageId, text, reply_markup: replyMarkup })
226
+ }
227
+ },
106
228
  }
107
229
  }
108
230
 
109
231
  allowed(userId) {
232
+ if (typeof this.isUserAllowed === 'function') return this.isUserAllowed(userId)
110
233
  return this.allowedUserIds.length === 0 || this.allowedUserIds.includes(Number(userId))
111
234
  }
112
235
 
236
+ async downloadByFileId(fileId, prefix, ext, name = '') {
237
+ const file = await this.getFile(fileId)
238
+ const bytes = await this.downloadFile(file.file_path)
239
+ const resolvedExt = ext || extOf(file.file_path, '') || ''
240
+ const path = saveToCache(this.media.cacheDir, cacheName(prefix, resolvedExt, name), bytes)
241
+ return { path, bytes, file }
242
+ }
243
+
244
+ async setReaction(chatId, messageId, emoji) {
245
+ if (!this.reactionsEnabled || !messageId) return
246
+ try {
247
+ await this.call('setMessageReaction', {
248
+ chat_id: chatId,
249
+ message_id: messageId,
250
+ reaction: emoji ? [{ type: 'emoji', emoji }] : [],
251
+ })
252
+ } catch (e) {
253
+ this.logger?.warn?.(`reaction: ${e.message}`)
254
+ }
255
+ }
256
+
113
257
  async handleMessage(msg) {
114
258
  const chatId = msg.chat.id
259
+ const chatType = msg.chat?.type || 'private'
115
260
  const userId = msg.from?.id ?? chatId
116
- if (!this.allowed(userId)) return
261
+ let text = msg.text ?? msg.caption ?? ''
262
+ const entities = msg.entities || msg.caption_entities || []
263
+ const gate = shouldProcessTelegramMessage({
264
+ chatType,
265
+ text,
266
+ entities,
267
+ replyTo: msg.reply_to_message,
268
+ botId: this.botId,
269
+ botUsername: this.botUsername,
270
+ groupsEnabled: this.groupsEnabled,
271
+ requireMention: this.groupRequireMention,
272
+ })
273
+ if (!gate.ok) return
274
+
275
+ if (!this.allowed(userId)) {
276
+ if (chatType !== 'private') return
277
+ if (this.onUnauthorized) {
278
+ await this.onUnauthorized({
279
+ platform: 'telegram', chatId, userId, threadId: msg.message_thread_id || 0,
280
+ username: msg.from?.username || '',
281
+ reply: async (payload) => this.sendReply(chatId, msg.message_id, payload, msg.message_thread_id || 0),
282
+ })
283
+ }
284
+ return
285
+ }
286
+
287
+ text = stripBotCommandSuffix(text, this.botUsername)
117
288
  const threadId = msg.message_thread_id || 0
118
- const cacheDir = this.media.cacheDir
119
289
  const maxDocBytes = this.media.maxDocBytes ?? 20 * 1024 * 1024
120
290
  const maxTextInjectBytes = this.media.maxTextInjectBytes ?? 100 * 1024
121
- let text = msg.text ?? msg.caption ?? ''
122
291
  const attachments = []
123
- const replyText = msg.reply_to_message ? (msg.reply_to_message.text ?? msg.reply_to_message.caption ?? '') : ''
292
+ const replyMsg = msg.reply_to_message
293
+ let replyText = ''
294
+ if (replyMsg) {
295
+ const quoted = replyMsg.text ?? replyMsg.caption ?? ''
296
+ const quoteFrag = msg.quote?.text || ''
297
+ replyText = quoteFrag
298
+ ? `${quoted}${quoted ? '\n' : ''}[цитата: ${quoteFrag}]`
299
+ : quoted
300
+ }
124
301
 
125
302
  if (msg.photo?.length) {
126
303
  const largest = msg.photo[msg.photo.length - 1]
127
- const file = await this.getFile(largest.file_id)
128
- const bytes = await this.downloadFile(file.file_path)
304
+ const { path, file } = await this.downloadByFileId(largest.file_id, 'photo', extOf('', '') || '.jpg')
129
305
  const ext = extOf(file.file_path, '') || '.jpg'
130
- attachments.push({ kind: 'photo', path: saveToCache(cacheDir, cacheName('photo', ext, ''), bytes), mime: IMAGE_EXT_TO_MIME[ext] || 'image/jpeg' })
306
+ attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || 'image/jpeg' })
307
+ }
308
+ if (msg.sticker) {
309
+ const st = msg.sticker
310
+ if (st.is_video) {
311
+ try {
312
+ const { path } = await this.downloadByFileId(st.file_id, 'sticker-video', '.webm', st.file_unique_id || '')
313
+ attachments.push({ kind: 'animation', path, mime: 'video/webm', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webm` })
314
+ if (st.emoji) text = `${text}\n[Видео-стикер ${st.emoji}]`.trim()
315
+ } catch (e) {
316
+ text = `${text}\n[Видео-стикер ${st.emoji || ''} (не скачан: ${e.message})]`.trim()
317
+ }
318
+ } else if (st.is_animated) {
319
+ try {
320
+ const { path } = await this.downloadByFileId(st.file_id, 'sticker-anim', '.tgs', st.file_unique_id || '')
321
+ attachments.push({ kind: 'document', path, mime: 'application/x-tgsticker', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.tgs` })
322
+ if (st.emoji) text = `${text}\n[Анимированный стикер ${st.emoji}]`.trim()
323
+ } catch (e) {
324
+ text = `${text}\n[Анимированный стикер ${st.emoji || ''} (не скачан: ${e.message})]`.trim()
325
+ }
326
+ } else {
327
+ const { path } = await this.downloadByFileId(st.file_id, 'sticker', '.webp', st.file_unique_id || '')
328
+ attachments.push({ kind: 'sticker', path, mime: 'image/webp', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webp` })
329
+ }
131
330
  }
132
331
  if (msg.voice) {
133
- const file = await this.getFile(msg.voice.file_id)
134
- const bytes = await this.downloadFile(file.file_path)
135
- attachments.push({ kind: 'voice', path: saveToCache(cacheDir, cacheName('voice', '.ogg', ''), bytes), mime: 'audio/ogg' })
332
+ const { path } = await this.downloadByFileId(msg.voice.file_id, 'voice', '.ogg')
333
+ attachments.push({ kind: 'voice', path, mime: 'audio/ogg' })
136
334
  }
137
335
  if (msg.audio) {
138
- const file = await this.getFile(msg.audio.file_id)
139
- const bytes = await this.downloadFile(file.file_path)
140
336
  const ext = extOf(msg.audio.file_name, msg.audio.mime_type) || '.mp3'
141
- attachments.push({ kind: 'audio', path: saveToCache(cacheDir, cacheName('audio', ext, msg.audio.file_name || ''), bytes), mime: msg.audio.mime_type || 'audio/mpeg' })
337
+ const { path } = await this.downloadByFileId(msg.audio.file_id, 'audio', ext, msg.audio.file_name || '')
338
+ attachments.push({ kind: 'audio', path, mime: msg.audio.mime_type || 'audio/mpeg' })
339
+ }
340
+ if (msg.video) {
341
+ const ext = extOf(msg.video.file_name, msg.video.mime_type) || '.mp4'
342
+ if ((msg.video.file_size || 0) > maxDocBytes) {
343
+ text = `${text}\n[Video too large]`.trim()
344
+ } else {
345
+ const { path } = await this.downloadByFileId(msg.video.file_id, 'video', ext, msg.video.file_name || '')
346
+ attachments.push({ kind: 'video', path, mime: msg.video.mime_type || VIDEO_EXT_TO_MIME[ext] || 'video/mp4', name: msg.video.file_name || basename(path) })
347
+ }
348
+ }
349
+ if (msg.video_note) {
350
+ const vn = msg.video_note
351
+ if ((vn.file_size || 0) > maxDocBytes) {
352
+ text = `${text}\n[Video note too large]`.trim()
353
+ } else {
354
+ const { path } = await this.downloadByFileId(vn.file_id, 'videonote', '.mp4')
355
+ attachments.push({ kind: 'video', path, mime: 'video/mp4', name: 'video_note.mp4' })
356
+ }
357
+ }
358
+ if (msg.animation) {
359
+ const an = msg.animation
360
+ const ext = extOf(an.file_name, an.mime_type) || '.mp4'
361
+ if ((an.file_size || 0) > maxDocBytes) {
362
+ text = `${text}\n[Animation too large]`.trim()
363
+ } else {
364
+ const { path } = await this.downloadByFileId(an.file_id, 'anim', ext, an.file_name || '')
365
+ attachments.push({ kind: 'animation', path, mime: an.mime_type || 'video/mp4', name: an.file_name || basename(path) })
366
+ }
142
367
  }
143
368
  if (msg.document) {
144
369
  const doc = msg.document
@@ -147,26 +372,93 @@ export class TelegramAdapter {
147
372
  if (doc.file_size > maxDocBytes) {
148
373
  text = `${text}\n[Document too large: ${doc.file_name || 'file'}]`.trim()
149
374
  } else if (kind === 'unsupported') {
150
- text = `${text}\n[Unsupported document type: ${doc.file_name || ext}]`.trim()
375
+ const { path } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
376
+ attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
151
377
  } else {
152
- const file = await this.getFile(doc.file_id)
153
- const bytes = await this.downloadFile(file.file_path)
154
- const path = saveToCache(cacheDir, cacheName('doc', ext, doc.file_name || ''), bytes)
378
+ const { path, bytes } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
155
379
  if (kind === 'image') attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || doc.mime_type || 'image/jpeg', name: doc.file_name })
156
380
  else if (kind === 'video') attachments.push({ kind: 'video', path, mime: VIDEO_EXT_TO_MIME[ext] || doc.mime_type || 'video/mp4', name: doc.file_name })
157
- else if (bytes.length <= maxTextInjectBytes && ['.md', '.txt', '.csv', '.log', '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.ts', '.py', '.sh'].includes(ext)) {
381
+ else if (bytes.length <= maxTextInjectBytes && TEXT_INJECT_EXTS.has(ext)) {
158
382
  const body = new TextDecoder('utf-8', { fatal: false }).decode(bytes).slice(0, maxTextInjectBytes)
159
383
  text = `${text}\n\n[Document ${doc.file_name}]\n${body}`.trim()
160
384
  } else attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
161
385
  }
162
386
  }
163
387
 
164
- const reply = async (payload) => this.sendReply(chatId, msg.message_id, payload, threadId)
388
+ const messageId = msg.message_id
389
+ const reply = async (payload) => this.sendReply(chatId, messageId, payload, threadId)
165
390
  const typing = async () => { try { await this.call('sendChatAction', { chat_id: chatId, action: 'typing', ...telegramThreadParams(threadId) }) } catch {} }
391
+ const startStream = async () => this.startStreamMessage(chatId, messageId, threadId)
392
+ const startProgress = async () => this.startProgressMessage(chatId, messageId, threadId)
393
+ const react = async (emoji) => this.setReaction(chatId, messageId, emoji)
166
394
 
167
395
  await this.onMessage({
168
- platform: 'telegram', chatId, userId, threadId, text, attachments, replyText, messageId: msg.message_id, reply, typing,
396
+ platform: 'telegram', chatId, userId, threadId, chatType, text, attachments, replyText,
397
+ messageId, reply, typing, startStream, startProgress, react,
398
+ })
399
+ }
400
+
401
+ async startProgressMessage(chatId, replyTo, threadId = 0) {
402
+ const result = await this.call('sendMessage', {
403
+ chat_id: chatId,
404
+ text: '⏳ Думаю…',
405
+ reply_to_message_id: replyTo,
406
+ ...telegramThreadParams(threadId),
169
407
  })
408
+ const messageId = result?.message_id
409
+ return {
410
+ messageId,
411
+ edit: async (text) => {
412
+ const plain = String(text || '⏳ Думаю…').slice(0, TELEGRAM_MAX)
413
+ try {
414
+ await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '⏳ Думаю…' })
415
+ } catch (err) {
416
+ const msg = String(err.message || '')
417
+ if (!msg.includes('message is not modified')) this.logger?.warn?.(`progress edit: ${err.message}`)
418
+ }
419
+ },
420
+ remove: async () => {
421
+ try { await this.call('deleteMessage', { chat_id: chatId, message_id: messageId }) } catch {}
422
+ },
423
+ }
424
+ }
425
+
426
+ async startStreamMessage(chatId, replyTo, threadId = 0) {
427
+ const result = await this.call('sendMessage', {
428
+ chat_id: chatId,
429
+ text: '…',
430
+ reply_to_message_id: replyTo,
431
+ ...telegramThreadParams(threadId),
432
+ })
433
+ const messageId = result?.message_id
434
+ return {
435
+ messageId,
436
+ edit: async (text) => {
437
+ const plain = String(text || '…').slice(0, TELEGRAM_MAX)
438
+ try {
439
+ await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '…' })
440
+ } catch (err) {
441
+ const msg = String(err.message || '')
442
+ if (!msg.includes('message is not modified')) throw err
443
+ }
444
+ },
445
+ finalize: async (text, payload = {}) => {
446
+ const { text: formatted, parseMode } = this.formatOutgoingText(String(text || ''), payload)
447
+ const chunk = splitText(formatted, TELEGRAM_MAX)[0] || '…'
448
+ const params = { chat_id: chatId, message_id: messageId, text: chunk }
449
+ if (parseMode) params.parse_mode = parseMode
450
+ try {
451
+ await this.call('editMessageText', params)
452
+ } catch (err) {
453
+ if (!parseMode) {
454
+ const msg = String(err.message || '')
455
+ if (!msg.includes('message is not modified')) throw err
456
+ return
457
+ }
458
+ await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: splitText(String(text || ''), TELEGRAM_MAX)[0] || '…' })
459
+ }
460
+ },
461
+ }
170
462
  }
171
463
 
172
464
  async sendMedia(chatId, file, threadId = 0) {
@@ -176,34 +468,65 @@ export class TelegramAdapter {
176
468
  if (thread.message_thread_id) form.append('message_thread_id', String(thread.message_thread_id))
177
469
  const blob = new Blob([file.bytes])
178
470
  const name = safeName(file.name || 'file')
179
- if (file.kind === 'photo') { form.append('photo', blob, name); return this.callMultipart('sendPhoto', form) }
180
- if (file.kind === 'voice') { form.append('voice', blob, name); return this.callMultipart('sendVoice', form) }
181
- if (file.kind === 'audio') { form.append('audio', blob, name); return this.callMultipart('sendAudio', form) }
471
+ if (file.kind === 'photo') { form.append('photo', blob, name); return this.sendWithRetry('sendPhoto', form, { multipart: true }) }
472
+ if (file.kind === 'voice') { form.append('voice', blob, name); return this.sendWithRetry('sendVoice', form, { multipart: true }) }
473
+ if (file.kind === 'audio') { form.append('audio', blob, name); return this.sendWithRetry('sendAudio', form, { multipart: true }) }
474
+ if (file.kind === 'video') { form.append('video', blob, name); return this.sendWithRetry('sendVideo', form, { multipart: true }) }
182
475
  form.append('document', blob, name)
183
- return this.callMultipart('sendDocument', form)
476
+ return this.sendWithRetry('sendDocument', form, { multipart: true })
184
477
  }
185
478
 
186
- async sendReply(chatId, replyTo, payload, threadId = 0) {
187
- const body = typeof payload === 'string' ? { text: payload } : (payload || {})
188
- const files = Array.isArray(body.files) ? body.files : []
189
- const text = String(body.text || '')
190
- const replyMarkup = body.replyMarkup
191
- for (const file of files) {
192
- try { await this.sendMedia(chatId, file, threadId) } catch (e) { this.logger?.warn?.(`send media: ${e.message}`) }
193
- }
194
- if (text) {
195
- for (const chunk of splitText(text, TELEGRAM_MAX)) {
479
+ formatOutgoingText(text, payload = {}) {
480
+ const mode = payload.parseMode === 'HTML' ? 'html'
481
+ : payload.parseMode === 'plain' ? 'plain'
482
+ : this.textFormat
483
+ return prepareTelegramText(text, mode)
484
+ }
485
+
486
+ async sendFormattedMessage(chatId, replyTo, text, payload, threadId, replyMarkup) {
487
+ const { text: formatted, parseMode } = this.formatOutgoingText(text, payload)
488
+ const chunks = splitText(formatted, TELEGRAM_MAX)
489
+ const plainChunks = splitText(text, TELEGRAM_MAX)
490
+ for (let i = 0; i < chunks.length; i++) {
491
+ const params = {
492
+ chat_id: chatId,
493
+ text: chunks[i],
494
+ reply_to_message_id: replyTo,
495
+ reply_markup: i === 0 ? replyMarkup : undefined,
496
+ ...telegramThreadParams(threadId),
497
+ }
498
+ if (parseMode) params.parse_mode = parseMode
499
+ try {
500
+ await this.sendWithRetry('sendMessage', params)
501
+ } catch (err) {
502
+ if (!parseMode) throw err
503
+ this.logger?.warn?.(`telegram HTML send failed, fallback plain: ${err.message}`)
196
504
  await this.call('sendMessage', {
197
505
  chat_id: chatId,
198
- text: chunk,
506
+ text: plainChunks[i] ?? chunks[i],
199
507
  reply_to_message_id: replyTo,
200
- reply_markup: replyMarkup,
508
+ reply_markup: i === 0 ? replyMarkup : undefined,
201
509
  ...telegramThreadParams(threadId),
202
510
  })
203
511
  }
204
512
  }
205
513
  }
206
514
 
515
+ async sendReply(chatId, replyTo, payload, threadId = 0) {
516
+ const body = typeof payload === 'string' ? { text: payload } : (payload || {})
517
+ const files = Array.isArray(body.files) ? body.files : []
518
+ const text = String(body.text || '')
519
+ const replyMarkup = body.replyMarkup
520
+ for (const file of files) {
521
+ try {
522
+ const bytes = file.bytes || (file.path ? await readFile(file.path) : null)
523
+ if (!bytes) continue
524
+ await this.sendMedia(chatId, { ...file, bytes }, threadId)
525
+ } catch (e) { this.logger?.warn?.(`send media: ${e.message}`) }
526
+ }
527
+ if (text) await this.sendFormattedMessage(chatId, replyTo, text, body, threadId, replyMarkup)
528
+ }
529
+
207
530
  async sendTo(chatId, payload, opts = {}) {
208
531
  return this.sendReply(chatId, undefined, payload, normalizeThreadId(opts.threadId))
209
532
  }