@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.
@@ -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
- const result = await this.call('sendMessage', {
473
- chat_id: chatId,
474
- text: '',
475
- reply_to_message_id: replyTo,
476
- ...telegramThreadParams(threadId),
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
- if (file.kind === 'photo' && !isSvg) { form.append('photo', blob, name); return this.sendWithRetry('sendPhoto', form, { multipart: true }) }
518
- if (file.kind === 'voice') { form.append('voice', blob, name); return this.sendWithRetry('sendVoice', form, { multipart: true }) }
519
- if (file.kind === 'audio') { form.append('audio', blob, name); return this.sendWithRetry('sendAudio', form, { multipart: true }) }
520
- if (file.kind === 'video') { form.append('video', blob, name); return this.sendWithRetry('sendVideo', form, { multipart: true }) }
521
- form.append('document', blob, name)
522
- return this.sendWithRetry('sendDocument', form, { multipart: true })
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
- await this.call('sendMessage', {
556
- chat_id: chatId,
557
- text: plainChunks[i] ?? chunks[i],
558
- reply_to_message_id: replyTo,
559
- reply_markup: i === 0 ? effectiveMarkup : undefined,
560
- ...telegramThreadParams(threadId),
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 (progressEnabled) stopTyping = startTypingHeartbeat(typing, 4000)
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
- timer = setTimeout(flush, Math.max(200, intervalMs))
74
- timer.unref?.()
75
- },
76
- async flush() {
77
- if (timer) { clearTimeout(timer); timer = null }
78
- flush()
79
- await inFlight
80
- // If there's still a pending difference (e.g. rate limit delay happened), wait and retry
81
- if (pending !== null && pending !== lastSent) {
82
- if (retryDelayMs > 0) {
83
- await new Promise((r) => setTimeout(r, retryDelayMs + 50))
84
- retryDelayMs = 0
85
- }
86
- try {
87
- await editFn(pending)
88
- lastSent = pending
89
- } catch {}
90
- }
91
- },
92
- }
93
- }
94
-
95
- export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
96
- if (typeof typingFn !== 'function') return () => {}
97
- typingFn().catch?.(() => {})
98
- const timer = setInterval(() => { typingFn().catch?.(() => {}) }, intervalMs)
99
- timer.unref?.()
100
- return () => clearInterval(timer)
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
+ }
@@ -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.18",
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",