@goodandready/dsh-messenger-gateway 0.3.18 → 0.3.20
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.js +708 -633
- package/lib/client.js +5 -0
- package/lib/commands.js +70 -47
- package/lib/config.js +104 -98
- package/lib/gateway.js +1539 -1407
- package/lib/index.js +500 -488
- package/lib/stream.js +114 -101
- package/lib/telegram-errors.js +51 -51
- package/package.json +1 -1
package/lib/stream.js
CHANGED
|
@@ -1,101 +1,114 @@
|
|
|
1
|
-
/** Helpers for Telegram streaming edits + progress. */
|
|
2
|
-
|
|
3
|
-
export function extractTextDelta(chunk) {
|
|
4
|
-
if (!chunk || typeof chunk !== 'object') return ''
|
|
5
|
-
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') return chunk.text
|
|
6
|
-
return ''
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function extractToolName(eventData) {
|
|
10
|
-
const name = eventData?.tool?.name || eventData?.toolName || eventData?.name
|
|
11
|
-
return name ? String(name) : ''
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function formatProgressLine(toolName) {
|
|
15
|
-
if (!toolName) return '⏳ Думаю…'
|
|
16
|
-
return `🔧 ${toolName}…`
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
|
|
20
|
-
const body = String(streamText || '').trimEnd()
|
|
21
|
-
const head = toolName ? `${formatProgressLine(toolName)}\n\n` : ''
|
|
22
|
-
const combined = head + (body || '…')
|
|
23
|
-
if (combined.length <= maxLen) return combined
|
|
24
|
-
return combined.slice(0, maxLen - 1) + '…'
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function parseRetryAfter(err) {
|
|
28
|
-
if (!err) return 0
|
|
29
|
-
const msg = err.message || String(err)
|
|
30
|
-
const match = /retry after (\d+)/i.exec(msg)
|
|
31
|
-
if (match) return parseInt(match[1], 10) * 1000
|
|
32
|
-
return 0
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function createEditScheduler(editFn, intervalMs = 1200) {
|
|
36
|
-
let pending = null
|
|
37
|
-
let timer = null
|
|
38
|
-
let lastSent = ''
|
|
39
|
-
let inFlight = Promise.resolve()
|
|
40
|
-
let retryDelayMs = 0
|
|
41
|
-
|
|
42
|
-
const flush = () => {
|
|
43
|
-
timer = null
|
|
44
|
-
if (pending === null || pending === lastSent) return
|
|
45
|
-
const text = pending
|
|
46
|
-
inFlight = inFlight.then(async () => {
|
|
47
|
-
try {
|
|
48
|
-
if (retryDelayMs > 0) {
|
|
49
|
-
const delay = retryDelayMs
|
|
50
|
-
retryDelayMs = 0
|
|
51
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
52
|
-
}
|
|
53
|
-
await editFn(text)
|
|
54
|
-
lastSent = text
|
|
55
|
-
} catch (err) {
|
|
56
|
-
const retry = parseRetryAfter(err)
|
|
57
|
-
if (retry > 0) {
|
|
58
|
-
retryDelayMs = retry
|
|
59
|
-
// Re-schedule flush with retry delay
|
|
60
|
-
if (!timer) {
|
|
61
|
-
timer = setTimeout(flush, retry + 100)
|
|
62
|
-
timer.unref?.()
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
push(text) {
|
|
71
|
-
pending = text
|
|
72
|
-
if (timer) return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
1
|
+
/** Helpers for Telegram streaming edits + progress. */
|
|
2
|
+
|
|
3
|
+
export function extractTextDelta(chunk) {
|
|
4
|
+
if (!chunk || typeof chunk !== 'object') return ''
|
|
5
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') return chunk.text
|
|
6
|
+
return ''
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function extractToolName(eventData) {
|
|
10
|
+
const name = eventData?.tool?.name || eventData?.toolName || eventData?.name
|
|
11
|
+
return name ? String(name) : ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatProgressLine(toolName) {
|
|
15
|
+
if (!toolName) return '⏳ Думаю…'
|
|
16
|
+
return `🔧 ${toolName}…`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
|
|
20
|
+
const body = String(streamText || '').trimEnd()
|
|
21
|
+
const head = toolName ? `${formatProgressLine(toolName)}\n\n` : ''
|
|
22
|
+
const combined = head + (body || '…')
|
|
23
|
+
if (combined.length <= maxLen) return combined
|
|
24
|
+
return combined.slice(0, maxLen - 1) + '…'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseRetryAfter(err) {
|
|
28
|
+
if (!err) return 0
|
|
29
|
+
const msg = err.message || String(err)
|
|
30
|
+
const match = /retry after (\d+)/i.exec(msg)
|
|
31
|
+
if (match) return parseInt(match[1], 10) * 1000
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs = 250) {
|
|
36
|
+
let pending = null
|
|
37
|
+
let timer = null
|
|
38
|
+
let lastSent = ''
|
|
39
|
+
let inFlight = Promise.resolve()
|
|
40
|
+
let retryDelayMs = 0
|
|
41
|
+
|
|
42
|
+
const flush = () => {
|
|
43
|
+
timer = null
|
|
44
|
+
if (pending === null || pending === lastSent) return
|
|
45
|
+
const text = pending
|
|
46
|
+
inFlight = inFlight.then(async () => {
|
|
47
|
+
try {
|
|
48
|
+
if (retryDelayMs > 0) {
|
|
49
|
+
const delay = retryDelayMs
|
|
50
|
+
retryDelayMs = 0
|
|
51
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
52
|
+
}
|
|
53
|
+
await editFn(text)
|
|
54
|
+
lastSent = text
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const retry = parseRetryAfter(err)
|
|
57
|
+
if (retry > 0) {
|
|
58
|
+
retryDelayMs = retry
|
|
59
|
+
// Re-schedule flush with retry delay
|
|
60
|
+
if (!timer) {
|
|
61
|
+
timer = setTimeout(flush, retry + 100)
|
|
62
|
+
timer.unref?.()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
push(text) {
|
|
71
|
+
pending = text
|
|
72
|
+
if (timer) return
|
|
73
|
+
// Fast first chunk: when lastSent is empty, dispatch quickly so user sees immediate response.
|
|
74
|
+
// Subsequent edits throttle to intervalMs to respect Telegram rate limits.
|
|
75
|
+
const delay = !lastSent ? Math.min(firstChunkDelayMs, intervalMs) : Math.max(200, intervalMs)
|
|
76
|
+
timer = setTimeout(flush, delay)
|
|
77
|
+
timer.unref?.()
|
|
78
|
+
},
|
|
79
|
+
async flush() {
|
|
80
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
81
|
+
flush()
|
|
82
|
+
await inFlight
|
|
83
|
+
// If there's still a pending difference (e.g. rate limit delay happened), wait and retry
|
|
84
|
+
if (pending !== null && pending !== lastSent) {
|
|
85
|
+
if (retryDelayMs > 0) {
|
|
86
|
+
await new Promise((r) => setTimeout(r, retryDelayMs + 50))
|
|
87
|
+
retryDelayMs = 0
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await editFn(pending)
|
|
91
|
+
lastSent = pending
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
|
|
99
|
+
if (typeof typingFn !== 'function') return () => {}
|
|
100
|
+
let active = true
|
|
101
|
+
const safeInvoke = () => {
|
|
102
|
+
if (!active) return
|
|
103
|
+
try {
|
|
104
|
+
typingFn()?.catch?.(() => {})
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
safeInvoke()
|
|
108
|
+
const timer = setInterval(safeInvoke, intervalMs)
|
|
109
|
+
timer.unref?.()
|
|
110
|
+
return () => {
|
|
111
|
+
active = false
|
|
112
|
+
clearInterval(timer)
|
|
113
|
+
}
|
|
114
|
+
}
|
package/lib/telegram-errors.js
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
// Network error classification for Telegram sends (mirrors Hermes adapter policy).
|
|
2
|
-
//
|
|
3
|
-
// The key distinction: some network failures mean the request NEVER left the
|
|
4
|
-
// process (connect/pool timeout, ECONNRESET before send) → resending is safe.
|
|
5
|
-
// Others (a generic timeout after the request may have reached Telegram) could
|
|
6
|
-
// duplicate a message if we resend → do NOT retry.
|
|
7
|
-
|
|
8
|
-
function rootCause(err) {
|
|
9
|
-
let e = err
|
|
10
|
-
let depth = 0
|
|
11
|
-
while (e && e.cause && e.cause !== e && depth < 10) {
|
|
12
|
-
e = e.cause
|
|
13
|
-
depth++
|
|
14
|
-
}
|
|
15
|
-
return e || err
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// True when a resend cannot duplicate a message: the request did not reach Telegram.
|
|
19
|
-
export function isResendSafeNetworkError(err) {
|
|
20
|
-
const c = rootCause(err)
|
|
21
|
-
const msg = String(c?.message || err?.message || '').toLowerCase()
|
|
22
|
-
if (/not sent to telegram|connect timeout|und_err_connect|econnreset|enotfound|econnrefused|ECONNRESET|ENOTFOUND|ECONNREFUSED/i.test(msg)) {
|
|
23
|
-
return true
|
|
24
|
-
}
|
|
25
|
-
// undici PoolTimeout message: "Request was *not* sent to Telegram."
|
|
26
|
-
if (/pool timeout|request was \*?not\*? sent/i.test(msg)) return true
|
|
27
|
-
return false
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// True for a 409 from getUpdates: a second bot instance polls the same token.
|
|
31
|
-
export function isPollingConflict(err) {
|
|
32
|
-
const msg = String(err?.message || '').toLowerCase()
|
|
33
|
-
return /terminated by other getupdates request|another bot instance is running|getupdates.*conflict|conflict.*getupdates/i.test(msg)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// True when the target chat/topic no longer exists (deleted/closed/upgraded).
|
|
37
|
-
// Such sends should not be retried and any pending ask binding must be pruned.
|
|
38
|
-
export function isTopicGoneError(err) {
|
|
39
|
-
const msg = String(err?.message || '').toLowerCase()
|
|
40
|
-
return /thread not found|message thread not found|topic[_ ]?(not found|closed|deleted)|chat (not found|was (upgraded|deleted))|group chat was (upgraded|deleted)/i.test(msg)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Calculates exponential backoff with max cap for polling retries.
|
|
45
|
-
* errorCount is 1-indexed.
|
|
46
|
-
*/
|
|
47
|
-
export function computePollBackoffMs(baseIntervalMs, errorCount, maxDelayMs = 30_000) {
|
|
48
|
-
const count = Math.max(1, Math.min(10, Number(errorCount) || 1))
|
|
49
|
-
const base = Math.max(100, Number(baseIntervalMs) || 500)
|
|
50
|
-
return Math.min(maxDelayMs, base * Math.pow(2, count - 1))
|
|
51
|
-
}
|
|
1
|
+
// Network error classification for Telegram sends (mirrors Hermes adapter policy).
|
|
2
|
+
//
|
|
3
|
+
// The key distinction: some network failures mean the request NEVER left the
|
|
4
|
+
// process (connect/pool timeout, ECONNRESET before send) → resending is safe.
|
|
5
|
+
// Others (a generic timeout after the request may have reached Telegram) could
|
|
6
|
+
// duplicate a message if we resend → do NOT retry.
|
|
7
|
+
|
|
8
|
+
function rootCause(err) {
|
|
9
|
+
let e = err
|
|
10
|
+
let depth = 0
|
|
11
|
+
while (e && e.cause && e.cause !== e && depth < 10) {
|
|
12
|
+
e = e.cause
|
|
13
|
+
depth++
|
|
14
|
+
}
|
|
15
|
+
return e || err
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// True when a resend cannot duplicate a message: the request did not reach Telegram.
|
|
19
|
+
export function isResendSafeNetworkError(err) {
|
|
20
|
+
const c = rootCause(err)
|
|
21
|
+
const msg = String(c?.message || err?.message || '').toLowerCase()
|
|
22
|
+
if (/not sent to telegram|connect timeout|und_err_connect|econnreset|enotfound|econnrefused|ECONNRESET|ENOTFOUND|ECONNREFUSED/i.test(msg)) {
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
// undici PoolTimeout message: "Request was *not* sent to Telegram."
|
|
26
|
+
if (/pool timeout|request was \*?not\*? sent/i.test(msg)) return true
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// True for a 409 from getUpdates: a second bot instance polls the same token.
|
|
31
|
+
export function isPollingConflict(err) {
|
|
32
|
+
const msg = String(err?.message || '').toLowerCase()
|
|
33
|
+
return /terminated by other getupdates request|another bot instance is running|getupdates.*conflict|conflict.*getupdates/i.test(msg)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// True when the target chat/topic no longer exists (deleted/closed/upgraded).
|
|
37
|
+
// Such sends should not be retried and any pending ask binding must be pruned.
|
|
38
|
+
export function isTopicGoneError(err) {
|
|
39
|
+
const msg = String(err?.message || '').toLowerCase()
|
|
40
|
+
return /thread not found|message thread not found|topic[_ ]?(not found|closed|deleted)|chat (not found|was (upgraded|deleted))|group chat was (upgraded|deleted)/i.test(msg)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Calculates exponential backoff with max cap for polling retries.
|
|
45
|
+
* errorCount is 1-indexed.
|
|
46
|
+
*/
|
|
47
|
+
export function computePollBackoffMs(baseIntervalMs, errorCount, maxDelayMs = 30_000) {
|
|
48
|
+
const count = Math.max(1, Math.min(10, Number(errorCount) || 1))
|
|
49
|
+
const base = Math.max(100, Number(baseIntervalMs) || 500)
|
|
50
|
+
return Math.min(maxDelayMs, base * Math.pow(2, count - 1))
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-messenger-gateway",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
4
4
|
"description": "Telegram messenger bridge for DeepSeek Harness: sessions, steer, homes, inline asks, notify bridge, and optional TTS voice notes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|