@goodandready/dsh-messenger-gateway 0.4.3 → 0.4.5

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,141 +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
+ 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
+
@@ -17,7 +17,8 @@ import { extractTelegramInboundMedia, probeTelegramHealth } from './telegram-inb
17
17
  const API = 'https://api.telegram.org'
18
18
  const TELEGRAM_MAX = 4096
19
19
 
20
- export { buildQuickActionsKeyboard, REMOVE_REPLY_KEYBOARD } from '../commands.js'
20
+ import { buildQuickActionsKeyboard, REMOVE_REPLY_KEYBOARD } from '../commands.js'
21
+ export { buildQuickActionsKeyboard, REMOVE_REPLY_KEYBOARD }
21
22
 
22
23
  export class TelegramAdapter {
23
24
  constructor(opts) {
package/lib/alerts.js CHANGED
@@ -1,105 +1,105 @@
1
- import { escapeHtml } from './telegram-format.js'
2
- import { normalizeThreadId } from './topics.js'
3
- import { t } from './locales/index.js'
4
-
5
- export function formatAlertMessage(type, payload = {}) {
6
- const timestamp = new Date().toLocaleTimeString()
7
-
8
- if (type === 'pairing') {
9
- const { userId, username, code } = payload
10
- const userStr = username ? `@${username} (id: <code>${userId}</code>)` : `id: <code>${userId}</code>`
11
- return [
12
- `🔐 <b>[Pairing Request]</b> <i>(${timestamp})</i>`,
13
- '',
14
- `User: ${userStr}`,
15
- `Pairing Code: <code>${code}</code>`,
16
- '',
17
- `To approve, send to bot:`,
18
- `<code>/pair ${code}</code>`,
19
- ].join('\n')
20
- }
21
-
22
- if (type === 'error') {
23
- const { message, code, sessionId, chatId, threadId } = payload
24
- const location = chatId ? `Chat: <code>${chatId}</code>${threadId ? ` / thread <code>${threadId}</code>` : ''}` : ''
25
- const sess = sessionId ? `Session: <code>${sessionId}</code>` : ''
26
- const meta = [location, sess].filter(Boolean).join('\n')
27
-
28
- return [
29
- `🚨 <b>[Gateway Error]</b> <i>(${timestamp})</i>`,
30
- meta ? `\n${meta}` : '',
31
- `Error code: <b>${escapeHtml(String(code || 'error'))}</b>`,
32
- `<code>${escapeHtml(String(message || 'unknown error'))}</code>`,
33
- ].filter(Boolean).join('\n')
34
- }
35
-
36
- if (type === 'status') {
37
- const { title, details } = payload
38
- return [
39
- `⚡ <b>[Gateway: ${escapeHtml(title || 'Status')}]</b> <i>(${timestamp})</i>`,
40
- details ? `\n${escapeHtml(details)}` : '',
41
- ].filter(Boolean).join('\n')
42
- }
43
-
44
- return `🔔 <b>[Alert: ${type}]</b> <i>(${timestamp})</i>\n${escapeHtml(JSON.stringify(payload))}`
45
- }
46
-
47
- export function resolveAlertTarget(gateway) {
48
- const alertsCfg = gateway?.config?.telegram?.alerts
49
- if (!alertsCfg || alertsCfg.enabled === false) return null
50
-
51
- // If a named home is specified
52
- if (alertsCfg.home) {
53
- const home = gateway.resolveHomeTarget('telegram', alertsCfg.home)
54
- if (home) return home
55
- }
56
-
57
- const chatId = alertsCfg.chatId
58
- if (!chatId) return null
59
-
60
- return {
61
- platform: 'telegram',
62
- chatId,
63
- threadId: normalizeThreadId(alertsCfg.threadId),
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
- }
1
+ import { escapeHtml } from './telegram-format.js'
2
+ import { normalizeThreadId } from './topics.js'
3
+ import { t } from './locales/index.js'
4
+
5
+ export function formatAlertMessage(type, payload = {}) {
6
+ const timestamp = new Date().toLocaleTimeString()
7
+
8
+ if (type === 'pairing') {
9
+ const { userId, username, code } = payload
10
+ const userStr = username ? `@${username} (id: <code>${userId}</code>)` : `id: <code>${userId}</code>`
11
+ return [
12
+ `🔐 <b>[Pairing Request]</b> <i>(${timestamp})</i>`,
13
+ '',
14
+ `User: ${userStr}`,
15
+ `Pairing Code: <code>${code}</code>`,
16
+ '',
17
+ `To approve, send to bot:`,
18
+ `<code>/pair ${code}</code>`,
19
+ ].join('\n')
20
+ }
21
+
22
+ if (type === 'error') {
23
+ const { message, code, sessionId, chatId, threadId } = payload
24
+ const location = chatId ? `Chat: <code>${chatId}</code>${threadId ? ` / thread <code>${threadId}</code>` : ''}` : ''
25
+ const sess = sessionId ? `Session: <code>${sessionId}</code>` : ''
26
+ const meta = [location, sess].filter(Boolean).join('\n')
27
+
28
+ return [
29
+ `🚨 <b>[Gateway Error]</b> <i>(${timestamp})</i>`,
30
+ meta ? `\n${meta}` : '',
31
+ `Error code: <b>${escapeHtml(String(code || 'error'))}</b>`,
32
+ `<code>${escapeHtml(String(message || 'unknown error'))}</code>`,
33
+ ].filter(Boolean).join('\n')
34
+ }
35
+
36
+ if (type === 'status') {
37
+ const { title, details } = payload
38
+ return [
39
+ `⚡ <b>[Gateway: ${escapeHtml(title || 'Status')}]</b> <i>(${timestamp})</i>`,
40
+ details ? `\n${escapeHtml(details)}` : '',
41
+ ].filter(Boolean).join('\n')
42
+ }
43
+
44
+ return `🔔 <b>[Alert: ${type}]</b> <i>(${timestamp})</i>\n${escapeHtml(JSON.stringify(payload))}`
45
+ }
46
+
47
+ export function resolveAlertTarget(gateway) {
48
+ const alertsCfg = gateway?.config?.telegram?.alerts
49
+ if (!alertsCfg || alertsCfg.enabled === false) return null
50
+
51
+ // If a named home is specified
52
+ if (alertsCfg.home) {
53
+ const home = gateway.resolveHomeTarget('telegram', alertsCfg.home)
54
+ if (home) return home
55
+ }
56
+
57
+ const chatId = alertsCfg.chatId
58
+ if (!chatId) return null
59
+
60
+ return {
61
+ platform: 'telegram',
62
+ chatId,
63
+ threadId: normalizeThreadId(alertsCfg.threadId),
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
+ }
package/lib/api-health.js CHANGED
@@ -1,38 +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
- }
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
+ }
package/lib/client.js CHANGED
@@ -430,14 +430,18 @@ window.__ModuleLoader__.load({
430
430
  }
431
431
  const ErrorBoundary = createErrorBoundary()
432
432
 
433
- function FallbackChevron() {
433
+ function FallbackChevron(props) {
434
434
  return React.createElement(
435
435
  'svg',
436
- { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
436
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', ...props },
437
437
  React.createElement('polyline', { points: '6 9 12 15 18 9' })
438
438
  )
439
439
  }
440
- const Chevron = FallbackChevron
440
+ function Chevron(props) {
441
+ const CoreChevron = props?.ctx?.get?.('icons')?.IconChevronDownOutline14
442
+ if (CoreChevron) return React.createElement(CoreChevron, props)
443
+ return React.createElement(FallbackChevron, props)
444
+ }
441
445
 
442
446
  function idsToText(ids) { return (Array.isArray(ids) ? ids : []).map(String).filter(Boolean).join(', ') }
443
447
  function textToIds(text) {
@@ -1512,6 +1516,7 @@ window.__ModuleLoader__.load({
1512
1516
 
1513
1517
  function PluginCard(props) {
1514
1518
  const [open, setOpen] = React.useState(false)
1519
+ const cardCtx = (props && props.ctx) || (typeof ctx !== 'undefined' ? ctx : undefined)
1515
1520
  const t = props?.t || makeT(props?.locale)
1516
1521
  React.useEffect(() => {
1517
1522
  ensureCss()
@@ -1529,7 +1534,7 @@ window.__ModuleLoader__.load({
1529
1534
  'div',
1530
1535
  { className: 'msgw-page-seat' },
1531
1536
  React.createElement(ErrorBoundary, null,
1532
- React.createElement(SettingsPage, { ...props, ctx: (props && props.ctx) || ctx, t })
1537
+ React.createElement(SettingsPage, { ...props, ctx: cardCtx, t })
1533
1538
  )
1534
1539
  )
1535
1540
  }
@@ -1564,7 +1569,7 @@ window.__ModuleLoader__.load({
1564
1569
  React.createElement(
1565
1570
  'span',
1566
1571
  { className: 'msgw-chev', style: { transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .16s' } },
1567
- React.createElement(Chevron)
1572
+ React.createElement(Chevron, { ctx: cardCtx })
1568
1573
  )
1569
1574
  ),
1570
1575
  open
@@ -1574,7 +1579,7 @@ window.__ModuleLoader__.load({
1574
1579
  React.createElement(
1575
1580
  ErrorBoundary,
1576
1581
  null,
1577
- React.createElement(SettingsPage, { ...props, ctx: (props && props.ctx) || ctx, t })
1582
+ React.createElement(SettingsPage, { ...props, ctx: cardCtx, t })
1578
1583
  )
1579
1584
  )
1580
1585
  : null