@goodandready/dsh-messenger-gateway 0.3.22 → 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.
package/LICENSE CHANGED
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
21
+ SOFTWARE.
package/cordis.patch.yml CHANGED
@@ -1,4 +1,4 @@
1
1
  - insert:
2
2
  - id: dsh-messenger-gateway
3
3
  name: '@goodandready/dsh-messenger-gateway'
4
- config: {}
4
+ config: {}
@@ -190,4 +190,4 @@ export class DiscordAdapter {
190
190
  const json = typeof res.json === 'function' ? await res.json().catch(() => ({})) : {}
191
191
  return { ok: true, messageId: json?.id }
192
192
  }
193
- }
193
+ }
@@ -154,4 +154,4 @@ export class SlackAdapter {
154
154
  }
155
155
  return { ok: true }
156
156
  }
157
- }
157
+ }
@@ -0,0 +1,141 @@
1
+ import { basename } from 'node:path'
2
+ import {
3
+ IMAGE_EXT_TO_MIME, VIDEO_EXT_TO_MIME, classifyDocument,
4
+ extOf, TELEGRAM_MAX_DOC_BYTES,
5
+ } from '../media.js'
6
+ import { TEXT_INJECT_EXTS } from '../documents.js'
7
+
8
+ export async function extractTelegramInboundMedia(adapter, msg, initialText, maxDocBytes = TELEGRAM_MAX_DOC_BYTES, maxTextInjectBytes = 100 * 1024) {
9
+ let text = initialText || ''
10
+ const attachments = []
11
+
12
+ if (msg.photo?.length) {
13
+ const largest = msg.photo[msg.photo.length - 1]
14
+ const { path, file } = await adapter.downloadByFileId(largest.file_id, 'photo', extOf('', '') || '.jpg')
15
+ const ext = extOf(file.file_path, '') || '.jpg'
16
+ attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || 'image/jpeg' })
17
+ }
18
+ if (msg.sticker) {
19
+ const st = msg.sticker
20
+ if (st.is_video) {
21
+ try {
22
+ const { path } = await adapter.downloadByFileId(st.file_id, 'sticker-video', '.webm', st.file_unique_id || '')
23
+ attachments.push({ kind: 'animation', path, mime: 'video/webm', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webm` })
24
+ if (st.emoji) text = `${text}\n[Video sticker ${st.emoji}]`.trim()
25
+ } catch (e) {
26
+ text = `${text}\n[Video sticker ${st.emoji || ''} (download failed: ${e.message})]`.trim()
27
+ }
28
+ } else if (st.is_animated) {
29
+ try {
30
+ const { path } = await adapter.downloadByFileId(st.file_id, 'sticker-anim', '.tgs', st.file_unique_id || '')
31
+ attachments.push({ kind: 'document', path, mime: 'application/x-tgsticker', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.tgs` })
32
+ if (st.emoji) text = `${text}\n[Animated sticker ${st.emoji}]`.trim()
33
+ } catch (e) {
34
+ text = `${text}\n[Animated sticker ${st.emoji || ''} (download failed: ${e.message})]`.trim()
35
+ }
36
+ } else {
37
+ const { path } = await adapter.downloadByFileId(st.file_id, 'sticker', '.webp', st.file_unique_id || '')
38
+ attachments.push({ kind: 'sticker', path, mime: 'image/webp', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webp` })
39
+ }
40
+ }
41
+ if (msg.voice) {
42
+ const { path } = await adapter.downloadByFileId(msg.voice.file_id, 'voice', '.ogg')
43
+ attachments.push({ kind: 'voice', path, mime: 'audio/ogg' })
44
+ }
45
+ if (msg.audio) {
46
+ const ext = extOf(msg.audio.file_name, msg.audio.mime_type) || '.mp3'
47
+ const { path } = await adapter.downloadByFileId(msg.audio.file_id, 'audio', ext, msg.audio.file_name || '')
48
+ attachments.push({ kind: 'audio', path, mime: msg.audio.mime_type || 'audio/mpeg' })
49
+ }
50
+ if (msg.video) {
51
+ const ext = extOf(msg.video.file_name, msg.video.mime_type) || '.mp4'
52
+ if ((msg.video.file_size || 0) > maxDocBytes) {
53
+ text = `${text}\n[Video too large]`.trim()
54
+ } else {
55
+ const { path } = await adapter.downloadByFileId(msg.video.file_id, 'video', ext, msg.video.file_name || '')
56
+ attachments.push({ kind: 'video', path, mime: msg.video.mime_type || VIDEO_EXT_TO_MIME[ext] || 'video/mp4', name: msg.video.file_name || basename(path) })
57
+ }
58
+ }
59
+ if (msg.video_note) {
60
+ const vn = msg.video_note
61
+ if ((vn.file_size || 0) > maxDocBytes) {
62
+ text = `${text}\n[Video note too large]`.trim()
63
+ } else {
64
+ const { path } = await adapter.downloadByFileId(vn.file_id, 'videonote', '.mp4')
65
+ attachments.push({ kind: 'video', path, mime: 'video/mp4', name: 'video_note.mp4' })
66
+ }
67
+ }
68
+ if (msg.animation) {
69
+ const an = msg.animation
70
+ const ext = extOf(an.file_name, an.mime_type) || '.mp4'
71
+ if ((an.file_size || 0) > maxDocBytes) {
72
+ text = `${text}\n[Animation too large]`.trim()
73
+ } else {
74
+ const { path } = await adapter.downloadByFileId(an.file_id, 'anim', ext, an.file_name || '')
75
+ attachments.push({ kind: 'animation', path, mime: an.mime_type || 'video/mp4', name: an.file_name || basename(path) })
76
+ }
77
+ }
78
+ if (msg.document) {
79
+ const doc = msg.document
80
+ const ext = extOf(doc.file_name, doc.mime_type)
81
+ const kind = classifyDocument(ext, doc.mime_type)
82
+ if (doc.file_size > maxDocBytes) {
83
+ text = `${text}\n[Document too large: ${doc.file_name || 'file'}]`.trim()
84
+ } else if (kind === 'unsupported') {
85
+ const { path } = await adapter.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
86
+ attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
87
+ } else {
88
+ const { path, bytes } = await adapter.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
89
+ if (kind === 'image') attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || doc.mime_type || 'image/jpeg', name: doc.file_name })
90
+ else if (kind === 'video') attachments.push({ kind: 'video', path, mime: VIDEO_EXT_TO_MIME[ext] || doc.mime_type || 'video/mp4', name: doc.file_name })
91
+ else if (bytes.length <= maxTextInjectBytes && TEXT_INJECT_EXTS.has(ext)) {
92
+ const body = new TextDecoder('utf-8', { fatal: false }).decode(bytes).slice(0, maxTextInjectBytes)
93
+ text = `${text}\n\n[Document ${doc.file_name}]\n${body}`.trim()
94
+ } else attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
95
+ }
96
+ }
97
+
98
+
99
+ return { text, attachments }
100
+ }
101
+
102
+ export async function probeTelegramHealth(adapter, timeoutMs = 10000) {
103
+ if (!adapter.token) {
104
+ return { ok: false, error: 'Telegram bot token is empty' }
105
+ }
106
+ const start = Date.now()
107
+ try {
108
+ const res = await fetch(`https://api.telegram.org/bot${adapter.token}/getMe`, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({}),
112
+ signal: AbortSignal.timeout(Math.max(1000, Number(timeoutMs) || 10000)),
113
+ })
114
+ const latencyMs = Date.now() - start
115
+ const json = await res.json().catch(() => ({}))
116
+ if (!res.ok || json.ok === false) {
117
+ return {
118
+ ok: false,
119
+ latencyMs,
120
+ error: json.description || `HTTP ${res.status}`,
121
+ }
122
+ }
123
+ const me = json.result || {}
124
+ adapter.botId = Number(me.id) || adapter.botId
125
+ adapter.botUsername = String(me.username || adapter.botUsername)
126
+ return {
127
+ ok: true,
128
+ latencyMs,
129
+ botId: adapter.botId,
130
+ botUsername: adapter.botUsername,
131
+ firstName: me.first_name || '',
132
+ }
133
+ } catch (err) {
134
+ return {
135
+ ok: false,
136
+ latencyMs: Date.now() - start,
137
+ error: err instanceof Error ? err.message : String(err),
138
+ }
139
+ }
140
+ }
141
+
@@ -1,7 +1,7 @@
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, saveToCache, safeName,
4
+ extOf, saveToCache, safeName, TELEGRAM_MAX_DOC_BYTES,
5
5
  } from '../media.js'
6
6
  import { TEXT_INJECT_EXTS } from '../documents.js'
7
7
  import { splitText } from '../text.js'
@@ -12,21 +12,12 @@ import {
12
12
  shouldProcessTelegramMessage, stripBotCommandSuffix,
13
13
  } from '../groups.js'
14
14
  import { isResendSafeNetworkError, isPollingConflict, isTopicGoneError, computePollBackoffMs } from '../telegram-errors.js'
15
+ import { extractTelegramInboundMedia, probeTelegramHealth } from './telegram-inbound.js'
15
16
 
16
17
  const API = 'https://api.telegram.org'
17
18
  const TELEGRAM_MAX = 4096
18
19
 
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 }
20
+ export { buildQuickActionsKeyboard, REMOVE_REPLY_KEYBOARD } from '../commands.js'
30
21
 
31
22
  export class TelegramAdapter {
32
23
  constructor(opts) {
@@ -138,6 +129,8 @@ export class TelegramAdapter {
138
129
  return this.call('createForumTopic', {
139
130
  chat_id: chatId,
140
131
  name: String(name || '').slice(0, 128),
132
+ ...(options.iconColor ? { icon_color: options.iconColor } : {}),
133
+ ...(options.iconCustomEmojiId ? { icon_custom_emoji_id: options.iconCustomEmojiId } : {}),
141
134
  ...options,
142
135
  })
143
136
  }
@@ -180,27 +173,36 @@ export class TelegramAdapter {
180
173
  }
181
174
  if (this.transport === 'webhook') {
182
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')
183
177
  try {
184
178
  await this.call('deleteWebhook', { drop_pending_updates: false })
185
- } catch {}
179
+ } catch (err) {
180
+ this.logger?.debug?.('telegram deleteWebhook failed (safe to ignore):', err?.message || err)
181
+ }
186
182
  const params = {
187
183
  url: this.webhookUrl,
188
184
  allowed_updates: ['message', 'callback_query'],
189
185
  drop_pending_updates: false,
186
+ secret_token: this.webhookSecret,
190
187
  }
191
- if (this.webhookSecret) params.secret_token = this.webhookSecret
192
188
  await this.call('setWebhook', params)
193
189
  this.logger?.info?.(`dsh-messenger-gateway: telegram webhook set → ${this.webhookUrl}`)
194
190
  return
195
191
  }
196
- try { await this.call('deleteWebhook', { drop_pending_updates: false }) } catch {}
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
197
  this.poll()
198
198
  }
199
199
 
200
200
  stop() {
201
201
  this.stopped = true
202
202
  if (this.pollTimer) clearTimeout(this.pollTimer)
203
- if (this.statusIndicator) this.setStatusIndicator(this.statusOffline).catch(() => {})
203
+ if (this.statusIndicator) this.setStatusIndicator(this.statusOffline).catch((err) => {
204
+ this.logger?.debug?.('telegram setStatusIndicator offline failed:', err?.message || err)
205
+ })
204
206
  }
205
207
 
206
208
  schedulePoll(delayMs) {
@@ -356,9 +358,8 @@ export class TelegramAdapter {
356
358
 
357
359
  text = stripBotCommandSuffix(text, this.botUsername)
358
360
  const threadId = msg.message_thread_id || 0
359
- const maxDocBytes = this.media.maxDocBytes ?? 20 * 1024 * 1024
361
+ const maxDocBytes = this.media.maxDocBytes ?? TELEGRAM_MAX_DOC_BYTES
360
362
  const maxTextInjectBytes = this.media.maxTextInjectBytes ?? 100 * 1024
361
- const attachments = []
362
363
  const replyMsg = msg.reply_to_message
363
364
  let replyText = ''
364
365
  if (replyMsg) {
@@ -369,95 +370,19 @@ export class TelegramAdapter {
369
370
  : quoted
370
371
  }
371
372
 
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
- }
373
+ const inbound = await extractTelegramInboundMedia(this, msg, text, maxDocBytes, maxTextInjectBytes)
374
+ text = inbound.text
375
+ const attachments = inbound.attachments
457
376
 
458
377
  const messageId = msg.message_id
459
378
  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 {} }
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
+ }
461
386
  const startStream = async () => this.startStreamMessage(chatId, messageId, threadId)
462
387
  const startProgress = async () => this.startProgressMessage(chatId, messageId, threadId)
463
388
  const react = async (emoji) => this.setReaction(chatId, messageId, emoji)
@@ -488,7 +413,11 @@ export class TelegramAdapter {
488
413
  }
489
414
  },
490
415
  remove: async () => {
491
- try { await this.call('deleteMessage', { chat_id: chatId, message_id: messageId }) } catch {}
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
+ }
492
421
  },
493
422
  }
494
423
  }
@@ -662,51 +591,6 @@ export class TelegramAdapter {
662
591
  }
663
592
 
664
593
  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
- })
594
+ return probeTelegramHealth(this, timeoutMs)
711
595
  }
712
596
  }
package/lib/alerts.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { escapeHtml } from './telegram-format.js'
2
2
  import { normalizeThreadId } from './topics.js'
3
+ import { t } from './locales/index.js'
3
4
 
4
5
  export function formatAlertMessage(type, payload = {}) {
5
6
  const timestamp = new Date().toLocaleTimeString()
@@ -61,4 +62,44 @@ export function resolveAlertTarget(gateway) {
61
62
  chatId,
62
63
  threadId: normalizeThreadId(alertsCfg.threadId),
63
64
  }
64
- }
65
+ }
66
+
67
+ export async function handleSetAlertCommand(gw, input, locale) {
68
+ const { reply, userId, chatId, threadId = 0 } = input
69
+ if (!gw.isUserAllowed(userId)) return reply(t('msg.not_allowed', {}, locale))
70
+ const nextTg = {
71
+ ...gw.tg(),
72
+ alerts: {
73
+ ...(gw.tg().alerts || {}),
74
+ enabled: true,
75
+ chatId,
76
+ threadId: threadId || 0,
77
+ },
78
+ }
79
+ gw.config.telegram = nextTg
80
+ try { await gw.hooks?.persistHomes?.(nextTg) } catch (err) { gw.logger?.warn?.('persistHomes error:', err?.message || err) }
81
+ return reply(`🔔 This chat assigned as alert channel (chat: ${chatId}${threadId ? `, topic: ${threadId}` : ''}).`)
82
+ }
83
+
84
+ export async function handleAlertCommand(gw, input, parts) {
85
+ const { reply, userId } = input
86
+ const sub = parts[1]?.toLowerCase()
87
+ if (sub === 'test') {
88
+ const target = resolveAlertTarget(gw)
89
+ if (!target) return reply('Alert channel not configured. Configure: /setalert')
90
+ await gw.sendAlert('status', { title: 'Test Alert', details: `Sent by user ID ${userId}` })
91
+ return reply('Test alert sent to alert channel.')
92
+ }
93
+ const target = resolveAlertTarget(gw)
94
+ const alertsCfg = gw.tg().alerts || {}
95
+ return reply([
96
+ '🔔 <b>Alert Channel:</b>',
97
+ `Status: ${alertsCfg.enabled ? 'enabled' : 'disabled'}`,
98
+ `Chat: ${target ? `${target.chatId}${target.threadId ? ` (topic: ${target.threadId})` : ''}` : '(not assigned)'}`,
99
+ `Events: ${(alertsCfg.events || ['error', 'pairing']).join(', ')}`,
100
+ '',
101
+ 'Commands:',
102
+ '/setalert — assign current chat as alert channel',
103
+ '/alert test — send test alert',
104
+ ].join('\n'))
105
+ }
@@ -0,0 +1,38 @@
1
+ export class ApiHealthTracker {
2
+ constructor(options = {}) {
3
+ this.logger = options.logger || console
4
+ this.degradedThreshold = Number(options.degradedThreshold) || 3
5
+ this.warnThreshold = Number(options.warnThreshold) || 5
6
+ this.consecutiveFailures = 0
7
+ this.lastError = null
8
+ }
9
+
10
+ recordFailure(op, err) {
11
+ this.consecutiveFailures++
12
+ this.lastError = {
13
+ op,
14
+ message: err?.message || String(err),
15
+ time: Date.now(),
16
+ }
17
+ this.logger?.debug?.(`dsh-messenger-gateway: Telegram API ${op} failed (${this.consecutiveFailures} consecutive):`, err?.message || err)
18
+ if (this.consecutiveFailures >= this.warnThreshold && this.consecutiveFailures % this.warnThreshold === 0) {
19
+ this.logger?.warn?.(`dsh-messenger-gateway: Telegram API experiencing repeated failures (${this.consecutiveFailures} in a row). Last op: ${op}, error: ${err?.message || err}`)
20
+ }
21
+ }
22
+
23
+ recordSuccess() {
24
+ this.consecutiveFailures = 0
25
+ }
26
+
27
+ isDegraded() {
28
+ return this.consecutiveFailures >= this.degradedThreshold
29
+ }
30
+
31
+ getSnapshot() {
32
+ return {
33
+ degraded: this.isDegraded(),
34
+ consecutiveFailures: this.consecutiveFailures,
35
+ lastError: this.lastError,
36
+ }
37
+ }
38
+ }