@goodandready/dsh-messenger-gateway 0.3.21 → 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.
package/lib/alerts.js CHANGED
@@ -1,64 +1,105 @@
1
- import { escapeHtml } from './telegram-format.js'
2
- import { normalizeThreadId } from './topics.js'
3
-
4
- export function formatAlertMessage(type, payload = {}) {
5
- const timestamp = new Date().toLocaleTimeString()
6
-
7
- if (type === 'pairing') {
8
- const { userId, username, code } = payload
9
- const userStr = username ? `@${username} (id: <code>${userId}</code>)` : `id: <code>${userId}</code>`
10
- return [
11
- `šŸ” <b>[Pairing Request]</b> <i>(${timestamp})</i>`,
12
- '',
13
- `User: ${userStr}`,
14
- `Pairing Code: <code>${code}</code>`,
15
- '',
16
- `To approve, send to bot:`,
17
- `<code>/pair ${code}</code>`,
18
- ].join('\n')
19
- }
20
-
21
- if (type === 'error') {
22
- const { message, code, sessionId, chatId, threadId } = payload
23
- const location = chatId ? `Chat: <code>${chatId}</code>${threadId ? ` / thread <code>${threadId}</code>` : ''}` : ''
24
- const sess = sessionId ? `Session: <code>${sessionId}</code>` : ''
25
- const meta = [location, sess].filter(Boolean).join('\n')
26
-
27
- return [
28
- `🚨 <b>[Gateway Error]</b> <i>(${timestamp})</i>`,
29
- meta ? `\n${meta}` : '',
30
- `Error code: <b>${escapeHtml(String(code || 'error'))}</b>`,
31
- `<code>${escapeHtml(String(message || 'unknown error'))}</code>`,
32
- ].filter(Boolean).join('\n')
33
- }
34
-
35
- if (type === 'status') {
36
- const { title, details } = payload
37
- return [
38
- `⚔ <b>[Gateway: ${escapeHtml(title || 'Status')}]</b> <i>(${timestamp})</i>`,
39
- details ? `\n${escapeHtml(details)}` : '',
40
- ].filter(Boolean).join('\n')
41
- }
42
-
43
- return `šŸ”” <b>[Alert: ${type}]</b> <i>(${timestamp})</i>\n${escapeHtml(JSON.stringify(payload))}`
44
- }
45
-
46
- export function resolveAlertTarget(gateway) {
47
- const alertsCfg = gateway?.config?.telegram?.alerts
48
- if (!alertsCfg || alertsCfg.enabled === false) return null
49
-
50
- // If a named home is specified
51
- if (alertsCfg.home) {
52
- const home = gateway.resolveHomeTarget('telegram', alertsCfg.home)
53
- if (home) return home
54
- }
55
-
56
- const chatId = alertsCfg.chatId
57
- if (!chatId) return null
58
-
59
- return {
60
- platform: 'telegram',
61
- chatId,
62
- threadId: normalizeThreadId(alertsCfg.threadId),
63
- }
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'))
64
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
+ }
package/lib/artifacts.js CHANGED
@@ -1,118 +1,118 @@
1
- export function extractMermaidDiagrams(text) {
2
- if (!text || typeof text !== 'string') return []
3
- const regex = /```mermaid\s*\n([\s\S]*?)```/gi
4
- const diagrams = []
5
- let match
6
- while ((match = regex.exec(text)) !== null) {
7
- diagrams.push({
8
- fullMatch: match[0],
9
- code: match[1].trim(),
10
- index: match.index,
11
- })
12
- }
13
- return diagrams
14
- }
15
-
16
- export function escapeSvgXml(str = '') {
17
- return String(str)
18
- .replace(/&/g, '&amp;')
19
- .replace(/</g, '&lt;')
20
- .replace(/>/g, '&gt;')
21
- .replace(/"/g, '&quot;')
22
- .replace(/'/g, '&apos;')
23
- }
24
-
25
- export function generateDiagramSvg(code, title = 'Mermaid Diagram') {
26
- const lines = code.split('\n').slice(0, 30)
27
- const lineSpans = lines.map((line, i) => {
28
- const escaped = escapeSvgXml(line)
29
- return `<tspan x="24" dy="${i === 0 ? '0' : '1.4em'}">${escaped}</tspan>`
30
- }).join('')
31
-
32
- const height = Math.max(160, Math.min(800, lines.length * 24 + 100))
33
- const safeTitle = escapeSvgXml(title)
34
-
35
- return `<?xml version="1.0" encoding="UTF-8"?>
36
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 ${height}" width="800" height="${height}">
37
- <defs>
38
- <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
39
- <stop offset="0%" stop-color="#1e1e2e"/>
40
- <stop offset="100%" stop-color="#181825"/>
41
- </linearGradient>
42
- </defs>
43
- <rect width="100%" height="100%" rx="12" fill="url(#bg)" stroke="#313244" stroke-width="2"/>
44
- <circle cx="28" cy="28" r="6" fill="#f38ba8" />
45
- <circle cx="48" cy="28" r="6" fill="#f9e2af" />
46
- <circle cx="68" cy="28" r="6" fill="#a6e3a1" />
47
- <text x="96" y="32" fill="#cdd6f4" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace" font-size="14" font-weight="600">${safeTitle}</text>
48
- <line x1="16" y1="48" x2="784" y2="48" stroke="#313244" stroke-width="1"/>
49
- <text x="24" y="76" fill="#a6adc8" font-family="'JetBrains Mono', 'Fira Code', monospace" font-size="13" xml:space="preserve">
50
- ${lineSpans}
51
- </text>
52
- </svg>`
53
- }
54
-
55
- export function formatMarkdownTables(text) {
56
- if (!text || typeof text !== 'string') return text
57
- const tableRegex = /((?:^|\n)\|[^\n]+\|\n\|[\s\-:|]+\|\n(?:\|[^\n]+\|\n?)+)/g
58
-
59
- return text.replace(tableRegex, (match) => {
60
- const rawLines = match.trim().split('\n')
61
- if (rawLines.length < 3) return match
62
-
63
- const rows = rawLines.map((line) => {
64
- const cells = line.split('|').slice(1, -1).map((c) => c.trim())
65
- return cells
66
- })
67
-
68
- const colCount = Math.max(...rows.map((r) => r.length))
69
- const colWidths = Array(colCount).fill(0)
70
-
71
- rows.forEach((row, rowIdx) => {
72
- if (rowIdx === 1) return
73
- row.forEach((cell, colIdx) => {
74
- colWidths[colIdx] = Math.max(colWidths[colIdx] || 0, cell.length)
75
- })
76
- })
77
-
78
- const formattedRows = rows.map((row, rowIdx) => {
79
- if (rowIdx === 1) {
80
- return colWidths.map((w) => '-'.repeat(Math.max(w, 3))).join(' | ')
81
- }
82
- return row.map((cell, colIdx) => cell.padEnd(colWidths[colIdx] || 3)).join(' | ')
83
- })
84
-
85
- const alignedTable = formattedRows.join('\n')
86
- return `\n\`\`\`\n${alignedTable}\n\`\`\`\n`
87
- })
88
- }
89
-
90
- export function processDiagramsAndTables(rawText, options = {}) {
91
- const { artifactPreviews = true } = options
92
- let text = String(rawText || '')
93
- const files = []
94
-
95
- if (artifactPreviews) {
96
- const diagrams = extractMermaidDiagrams(text)
97
- if (diagrams.length > 0) {
98
- let diagramIndex = 1
99
- for (const diag of diagrams) {
100
- const svgContent = generateDiagramSvg(diag.code, `Diagram ${diagramIndex}`)
101
- const base64 = Buffer.from(svgContent, 'utf-8').toString('base64')
102
- files.push({
103
- name: `diagram-${diagramIndex}.svg`,
104
- mime: 'image/svg+xml',
105
- kind: 'document',
106
- dataBase64: base64,
107
- bytes: Buffer.from(svgContent, 'utf-8'),
108
- })
109
-
110
- text = text.replace(diag.fullMatch, `\nšŸ“Š <b>[Diagram ${diagramIndex}: see attachment]</b>\n`)
111
- diagramIndex++
112
- }
113
- }
114
- text = formatMarkdownTables(text)
115
- }
116
-
117
- return { text, files, diagramsCount: files.length }
118
- }
1
+ export function extractMermaidDiagrams(text) {
2
+ if (!text || typeof text !== 'string') return []
3
+ const regex = /```mermaid\s*\n([\s\S]*?)```/gi
4
+ const diagrams = []
5
+ let match
6
+ while ((match = regex.exec(text)) !== null) {
7
+ diagrams.push({
8
+ fullMatch: match[0],
9
+ code: match[1].trim(),
10
+ index: match.index,
11
+ })
12
+ }
13
+ return diagrams
14
+ }
15
+
16
+ export function escapeSvgXml(str = '') {
17
+ return String(str)
18
+ .replace(/&/g, '&amp;')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;')
22
+ .replace(/'/g, '&apos;')
23
+ }
24
+
25
+ export function generateDiagramSvg(code, title = 'Mermaid Diagram') {
26
+ const lines = code.split('\n').slice(0, 30)
27
+ const lineSpans = lines.map((line, i) => {
28
+ const escaped = escapeSvgXml(line)
29
+ return `<tspan x="24" dy="${i === 0 ? '0' : '1.4em'}">${escaped}</tspan>`
30
+ }).join('')
31
+
32
+ const height = Math.max(160, Math.min(800, lines.length * 24 + 100))
33
+ const safeTitle = escapeSvgXml(title)
34
+
35
+ return `<?xml version="1.0" encoding="UTF-8"?>
36
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 ${height}" width="800" height="${height}">
37
+ <defs>
38
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
39
+ <stop offset="0%" stop-color="#1e1e2e"/>
40
+ <stop offset="100%" stop-color="#181825"/>
41
+ </linearGradient>
42
+ </defs>
43
+ <rect width="100%" height="100%" rx="12" fill="url(#bg)" stroke="#313244" stroke-width="2"/>
44
+ <circle cx="28" cy="28" r="6" fill="#f38ba8" />
45
+ <circle cx="48" cy="28" r="6" fill="#f9e2af" />
46
+ <circle cx="68" cy="28" r="6" fill="#a6e3a1" />
47
+ <text x="96" y="32" fill="#cdd6f4" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace" font-size="14" font-weight="600">${safeTitle}</text>
48
+ <line x1="16" y1="48" x2="784" y2="48" stroke="#313244" stroke-width="1"/>
49
+ <text x="24" y="76" fill="#a6adc8" font-family="'JetBrains Mono', 'Fira Code', monospace" font-size="13" xml:space="preserve">
50
+ ${lineSpans}
51
+ </text>
52
+ </svg>`
53
+ }
54
+
55
+ export function formatMarkdownTables(text) {
56
+ if (!text || typeof text !== 'string') return text
57
+ const tableRegex = /((?:^|\n)\|[^\n]+\|\n\|[\s\-:|]+\|\n(?:\|[^\n]+\|\n?)+)/g
58
+
59
+ return text.replace(tableRegex, (match) => {
60
+ const rawLines = match.trim().split('\n')
61
+ if (rawLines.length < 3) return match
62
+
63
+ const rows = rawLines.map((line) => {
64
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim())
65
+ return cells
66
+ })
67
+
68
+ const colCount = Math.max(...rows.map((r) => r.length))
69
+ const colWidths = Array(colCount).fill(0)
70
+
71
+ rows.forEach((row, rowIdx) => {
72
+ if (rowIdx === 1) return
73
+ row.forEach((cell, colIdx) => {
74
+ colWidths[colIdx] = Math.max(colWidths[colIdx] || 0, cell.length)
75
+ })
76
+ })
77
+
78
+ const formattedRows = rows.map((row, rowIdx) => {
79
+ if (rowIdx === 1) {
80
+ return colWidths.map((w) => '-'.repeat(Math.max(w, 3))).join(' | ')
81
+ }
82
+ return row.map((cell, colIdx) => cell.padEnd(colWidths[colIdx] || 3)).join(' | ')
83
+ })
84
+
85
+ const alignedTable = formattedRows.join('\n')
86
+ return `\n\`\`\`\n${alignedTable}\n\`\`\`\n`
87
+ })
88
+ }
89
+
90
+ export function processDiagramsAndTables(rawText, options = {}) {
91
+ const { artifactPreviews = true } = options
92
+ let text = String(rawText || '')
93
+ const files = []
94
+
95
+ if (artifactPreviews) {
96
+ const diagrams = extractMermaidDiagrams(text)
97
+ if (diagrams.length > 0) {
98
+ let diagramIndex = 1
99
+ for (const diag of diagrams) {
100
+ const svgContent = generateDiagramSvg(diag.code, `Diagram ${diagramIndex}`)
101
+ const base64 = Buffer.from(svgContent, 'utf-8').toString('base64')
102
+ files.push({
103
+ name: `diagram-${diagramIndex}.svg`,
104
+ mime: 'image/svg+xml',
105
+ kind: 'document',
106
+ dataBase64: base64,
107
+ bytes: Buffer.from(svgContent, 'utf-8'),
108
+ })
109
+
110
+ text = text.replace(diag.fullMatch, `\nšŸ“Š <b>[Diagram ${diagramIndex}: see attachment]</b>\n`)
111
+ diagramIndex++
112
+ }
113
+ }
114
+ text = formatMarkdownTables(text)
115
+ }
116
+
117
+ return { text, files, diagramsCount: files.length }
118
+ }