@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.
- package/lib/adapters/telegram-inbound.js +141 -141
- package/lib/adapters/telegram.js +2 -1
- package/lib/alerts.js +105 -105
- package/lib/api-health.js +38 -38
- package/lib/client.js +11 -6
- package/lib/commands.js +126 -126
- package/lib/forum-mirror.js +6 -6
- package/lib/gateway-callbacks.js +2 -1
- package/lib/gateway-commands.js +3 -0
- package/lib/gateway-turn.js +288 -285
- package/lib/gateway.js +15 -11
- package/lib/http.js +16 -3
- package/lib/index.js +13 -9
- package/lib/models.js +29 -2
- package/lib/scheduler.js +169 -169
- package/lib/telegram-errors.js +0 -0
- package/package.json +1 -1
|
@@ -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
|
+
|
package/lib/adapters/telegram.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1582
|
+
React.createElement(SettingsPage, { ...props, ctx: cardCtx, t })
|
|
1578
1583
|
)
|
|
1579
1584
|
)
|
|
1580
1585
|
: null
|