@goodandready/dsh-messenger-gateway 0.3.18 → 0.3.19
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 +77 -20
- package/lib/client.js +5 -0
- package/lib/gateway.js +3 -4
- package/lib/stream.js +114 -101
- package/lib/telegram-errors.js +51 -51
- package/package.json +1 -1
package/lib/adapters/telegram.js
CHANGED
|
@@ -11,7 +11,7 @@ import { normalizeThreadId, telegramThreadParams } from '../topics.js'
|
|
|
11
11
|
import {
|
|
12
12
|
shouldProcessTelegramMessage, stripBotCommandSuffix,
|
|
13
13
|
} from '../groups.js'
|
|
14
|
-
import { isResendSafeNetworkError, isPollingConflict, computePollBackoffMs } from '../telegram-errors.js'
|
|
14
|
+
import { isResendSafeNetworkError, isPollingConflict, isTopicGoneError, computePollBackoffMs } from '../telegram-errors.js'
|
|
15
15
|
|
|
16
16
|
const API = 'https://api.telegram.org'
|
|
17
17
|
const TELEGRAM_MAX = 4096
|
|
@@ -75,6 +75,7 @@ export class TelegramAdapter {
|
|
|
75
75
|
method: 'POST',
|
|
76
76
|
headers: { 'Content-Type': 'application/json' },
|
|
77
77
|
body: JSON.stringify(params),
|
|
78
|
+
keepalive: true,
|
|
78
79
|
signal: AbortSignal.timeout(timeoutMs),
|
|
79
80
|
})
|
|
80
81
|
const json = await res.json().catch(() => ({}))
|
|
@@ -87,6 +88,7 @@ export class TelegramAdapter {
|
|
|
87
88
|
const res = await fetch(`${API}/bot${this.token}/${method}`, {
|
|
88
89
|
method: 'POST',
|
|
89
90
|
body: form,
|
|
91
|
+
keepalive: true,
|
|
90
92
|
signal: AbortSignal.timeout(timeoutMs),
|
|
91
93
|
})
|
|
92
94
|
const json = await res.json().catch(() => ({}))
|
|
@@ -117,6 +119,7 @@ export class TelegramAdapter {
|
|
|
117
119
|
async downloadFile(filePath) {
|
|
118
120
|
const timeoutMs = (this.timeoutSeconds * 1000) + 30000
|
|
119
121
|
const res = await fetch(`${API}/file/bot${this.token}/${filePath}`, {
|
|
122
|
+
keepalive: true,
|
|
120
123
|
signal: AbortSignal.timeout(timeoutMs),
|
|
121
124
|
})
|
|
122
125
|
if (!res.ok) throw new Error(`telegram download HTTP ${res.status}`)
|
|
@@ -469,12 +472,26 @@ export class TelegramAdapter {
|
|
|
469
472
|
}
|
|
470
473
|
|
|
471
474
|
async startStreamMessage(chatId, replyTo, threadId = 0) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
475
|
+
let result
|
|
476
|
+
try {
|
|
477
|
+
result = await this.call('sendMessage', {
|
|
478
|
+
chat_id: chatId,
|
|
479
|
+
text: '…',
|
|
480
|
+
reply_to_message_id: replyTo,
|
|
481
|
+
...telegramThreadParams(threadId),
|
|
482
|
+
})
|
|
483
|
+
} catch (err) {
|
|
484
|
+
if (threadId && isTopicGoneError(err)) {
|
|
485
|
+
this.logger?.warn?.(`telegram stream start topic gone (thread ${threadId}), fallback main chat: ${err.message}`)
|
|
486
|
+
result = await this.call('sendMessage', {
|
|
487
|
+
chat_id: chatId,
|
|
488
|
+
text: '…',
|
|
489
|
+
reply_to_message_id: replyTo,
|
|
490
|
+
})
|
|
491
|
+
} else {
|
|
492
|
+
throw err
|
|
493
|
+
}
|
|
494
|
+
}
|
|
478
495
|
const messageId = result?.message_id
|
|
479
496
|
return {
|
|
480
497
|
messageId,
|
|
@@ -514,12 +531,30 @@ export class TelegramAdapter {
|
|
|
514
531
|
const blob = new Blob([file.bytes])
|
|
515
532
|
const name = safeName(file.name || 'file')
|
|
516
533
|
const isSvg = file.mime === 'image/svg+xml' || (file.name && file.name.toLowerCase().endsWith('.svg'))
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
534
|
+
const send = (m, f) => this.sendWithRetry(m, f, { multipart: true })
|
|
535
|
+
const method = (file.kind === 'photo' && !isSvg) ? 'sendPhoto'
|
|
536
|
+
: (file.kind === 'voice') ? 'sendVoice'
|
|
537
|
+
: (file.kind === 'audio') ? 'sendAudio'
|
|
538
|
+
: (file.kind === 'video') ? 'sendVideo'
|
|
539
|
+
: 'sendDocument'
|
|
540
|
+
const fieldName = (file.kind === 'photo' && !isSvg) ? 'photo'
|
|
541
|
+
: (file.kind === 'voice') ? 'voice'
|
|
542
|
+
: (file.kind === 'audio') ? 'audio'
|
|
543
|
+
: (file.kind === 'video') ? 'video'
|
|
544
|
+
: 'document'
|
|
545
|
+
form.append(fieldName, blob, name)
|
|
546
|
+
try {
|
|
547
|
+
return await send(method, form)
|
|
548
|
+
} catch (err) {
|
|
549
|
+
if (threadId && isTopicGoneError(err)) {
|
|
550
|
+
this.logger?.warn?.(`telegram sendMedia topic gone (thread ${threadId}), fallback main chat: ${err.message}`)
|
|
551
|
+
const fallbackForm = new FormData()
|
|
552
|
+
fallbackForm.append('chat_id', String(chatId))
|
|
553
|
+
fallbackForm.append(fieldName, blob, name)
|
|
554
|
+
return await send(method, fallbackForm)
|
|
555
|
+
}
|
|
556
|
+
throw err
|
|
557
|
+
}
|
|
523
558
|
}
|
|
524
559
|
|
|
525
560
|
formatOutgoingText(text, payload = {}) {
|
|
@@ -550,19 +585,41 @@ export class TelegramAdapter {
|
|
|
550
585
|
try {
|
|
551
586
|
await this.sendWithRetry('sendMessage', params)
|
|
552
587
|
} catch (err) {
|
|
588
|
+
if (threadId && isTopicGoneError(err)) {
|
|
589
|
+
this.logger?.warn?.(`telegram send topic gone (thread ${threadId}), fallback main chat: ${err.message}`)
|
|
590
|
+
params.message_thread_id = undefined
|
|
591
|
+
delete params.message_thread_id
|
|
592
|
+
await this.sendWithRetry('sendMessage', params)
|
|
593
|
+
continue
|
|
594
|
+
}
|
|
553
595
|
if (!parseMode) throw err
|
|
554
596
|
this.logger?.warn?.(`telegram HTML send failed, fallback plain: ${err.message}`)
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
597
|
+
try {
|
|
598
|
+
await this.call('sendMessage', {
|
|
599
|
+
chat_id: chatId,
|
|
600
|
+
text: plainChunks[i] ?? chunks[i],
|
|
601
|
+
reply_to_message_id: replyTo,
|
|
602
|
+
reply_markup: i === 0 ? effectiveMarkup : undefined,
|
|
603
|
+
...telegramThreadParams(threadId),
|
|
604
|
+
})
|
|
605
|
+
} catch (err2) {
|
|
606
|
+
if (threadId && isTopicGoneError(err2)) {
|
|
607
|
+
this.logger?.warn?.(`telegram plain send topic gone (thread ${threadId}), fallback main chat: ${err2.message}`)
|
|
608
|
+
await this.call('sendMessage', {
|
|
609
|
+
chat_id: chatId,
|
|
610
|
+
text: plainChunks[i] ?? chunks[i],
|
|
611
|
+
reply_to_message_id: replyTo,
|
|
612
|
+
reply_markup: i === 0 ? effectiveMarkup : undefined,
|
|
613
|
+
})
|
|
614
|
+
} else {
|
|
615
|
+
throw err2
|
|
616
|
+
}
|
|
617
|
+
}
|
|
562
618
|
}
|
|
563
619
|
}
|
|
564
620
|
}
|
|
565
621
|
|
|
622
|
+
|
|
566
623
|
async sendReply(chatId, replyTo, payload, threadId = 0) {
|
|
567
624
|
const body = typeof payload === 'string' ? { text: payload } : (payload || {})
|
|
568
625
|
const files = Array.isArray(body.files) ? body.files : []
|
package/lib/client.js
CHANGED
|
@@ -496,6 +496,11 @@ window.__ModuleLoader__.load({
|
|
|
496
496
|
React.useEffect(() => {
|
|
497
497
|
loadStatus()
|
|
498
498
|
loadPairing()
|
|
499
|
+
const timer = setInterval(() => {
|
|
500
|
+
loadStatus()
|
|
501
|
+
loadPairing()
|
|
502
|
+
}, 10000)
|
|
503
|
+
return () => clearInterval(timer)
|
|
499
504
|
}, [loadStatus, loadPairing])
|
|
500
505
|
|
|
501
506
|
const handleSmoke = async () => {
|
package/lib/gateway.js
CHANGED
|
@@ -1199,12 +1199,13 @@ export class Gateway {
|
|
|
1199
1199
|
if (typeof react === 'function' && tg.reactionsEnabled !== false) {
|
|
1200
1200
|
react('👀').catch?.(() => {})
|
|
1201
1201
|
}
|
|
1202
|
-
if (
|
|
1202
|
+
if (typeof typing === 'function') stopTyping = startTypingHeartbeat(typing, 4000)
|
|
1203
1203
|
if (streaming) {
|
|
1204
1204
|
try {
|
|
1205
1205
|
stream = await startStream()
|
|
1206
1206
|
scheduler = createEditScheduler((text) => stream.edit(text), Number(tg.streamEditIntervalMs) || 1200)
|
|
1207
1207
|
collector.onStream = (text, toolName) => {
|
|
1208
|
+
if (text) stopTyping()
|
|
1208
1209
|
if (!progressEnabled && !text) return
|
|
1209
1210
|
scheduler.push(buildStreamPreview(text, progressEnabled ? toolName : ''))
|
|
1210
1211
|
}
|
|
@@ -1223,12 +1224,10 @@ export class Gateway {
|
|
|
1223
1224
|
} catch (e) {
|
|
1224
1225
|
this.ctx.logger?.warn?.(`progress start: ${e.message}`)
|
|
1225
1226
|
progress = null
|
|
1226
|
-
if (typing) await typing()
|
|
1227
1227
|
}
|
|
1228
|
-
} else if (typing) {
|
|
1229
|
-
await typing()
|
|
1230
1228
|
}
|
|
1231
1229
|
|
|
1230
|
+
|
|
1232
1231
|
const content = await this.buildUserContent(input, signal)
|
|
1233
1232
|
chat.agent.followup(createUserMessage({
|
|
1234
1233
|
content: ensureContentArray(content),
|
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.19",
|
|
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",
|