@goodandready/dsh-messenger-gateway 0.3.14 → 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/README.md CHANGED
@@ -104,6 +104,12 @@ In addition to Telegram, outbound messages can be dispatched to Discord and Slac
104
104
  - `notifyBridge` — forward non-messenger web session events to a home
105
105
  - Bot token is a DSH secret field — never commit it
106
106
 
107
+ ## Client UI & Settings Card
108
+
109
+ - Mounted into `settings.plugin.item` on the Plugins tab (`key: dsh-messenger-gateway`).
110
+ - Configuration is managed reactively via `settingsScope.bind({ namespace: 'dsh-messenger-gateway' })` with snapshot status checking (`loading`, `unavailable`, `ready`).
111
+ - Protected dictionary registration: duplicate registration attempts on page reload are safely caught with non-fatal warnings, ensuring the settings card never fails to mount.
112
+
107
113
  ## Requirements
108
114
 
109
115
  - DeepSeek Harness web (or compatible) profile
@@ -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 } 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
@@ -55,6 +55,7 @@ export class TelegramAdapter {
55
55
  this.sendRetryMax = 2
56
56
  this.sendRetryBaseMs = 400
57
57
  this.pollingConflict = false
58
+ this.pollErrorCount = 0
58
59
  this.webhookUrl = String(opts.webhookUrl || '').trim()
59
60
  this.webhookSecret = String(opts.webhookSecret || '').trim()
60
61
  this.offset = 0
@@ -74,6 +75,7 @@ export class TelegramAdapter {
74
75
  method: 'POST',
75
76
  headers: { 'Content-Type': 'application/json' },
76
77
  body: JSON.stringify(params),
78
+ keepalive: true,
77
79
  signal: AbortSignal.timeout(timeoutMs),
78
80
  })
79
81
  const json = await res.json().catch(() => ({}))
@@ -86,6 +88,7 @@ export class TelegramAdapter {
86
88
  const res = await fetch(`${API}/bot${this.token}/${method}`, {
87
89
  method: 'POST',
88
90
  body: form,
91
+ keepalive: true,
89
92
  signal: AbortSignal.timeout(timeoutMs),
90
93
  })
91
94
  const json = await res.json().catch(() => ({}))
@@ -116,6 +119,7 @@ export class TelegramAdapter {
116
119
  async downloadFile(filePath) {
117
120
  const timeoutMs = (this.timeoutSeconds * 1000) + 30000
118
121
  const res = await fetch(`${API}/file/bot${this.token}/${filePath}`, {
122
+ keepalive: true,
119
123
  signal: AbortSignal.timeout(timeoutMs),
120
124
  })
121
125
  if (!res.ok) throw new Error(`telegram download HTTP ${res.status}`)
@@ -181,9 +185,10 @@ export class TelegramAdapter {
181
185
  if (this.statusIndicator) this.setStatusIndicator(this.statusOffline).catch(() => {})
182
186
  }
183
187
 
184
- schedulePoll() {
188
+ schedulePoll(delayMs) {
185
189
  if (this.stopped) return
186
- this.pollTimer = setTimeout(() => this.poll(), this.pollIntervalMs)
190
+ const delay = delayMs !== undefined ? delayMs : this.pollIntervalMs
191
+ this.pollTimer = setTimeout(() => this.poll(), delay)
187
192
  this.pollTimer.unref?.()
188
193
  }
189
194
 
@@ -196,6 +201,7 @@ export class TelegramAdapter {
196
201
  allowed_updates: ['message', 'callback_query'],
197
202
  })
198
203
  this.pollingConflict = false
204
+ this.pollErrorCount = 0
199
205
  for (const update of updates || []) {
200
206
  this.offset = Math.max(this.offset, update.update_id + 1)
201
207
  await this.dispatchUpdate(update)
@@ -466,12 +472,26 @@ export class TelegramAdapter {
466
472
  }
467
473
 
468
474
  async startStreamMessage(chatId, replyTo, threadId = 0) {
469
- const result = await this.call('sendMessage', {
470
- chat_id: chatId,
471
- text: '',
472
- reply_to_message_id: replyTo,
473
- ...telegramThreadParams(threadId),
474
- })
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
+ }
475
495
  const messageId = result?.message_id
476
496
  return {
477
497
  messageId,
@@ -511,12 +531,30 @@ export class TelegramAdapter {
511
531
  const blob = new Blob([file.bytes])
512
532
  const name = safeName(file.name || 'file')
513
533
  const isSvg = file.mime === 'image/svg+xml' || (file.name && file.name.toLowerCase().endsWith('.svg'))
514
- if (file.kind === 'photo' && !isSvg) { form.append('photo', blob, name); return this.sendWithRetry('sendPhoto', form, { multipart: true }) }
515
- if (file.kind === 'voice') { form.append('voice', blob, name); return this.sendWithRetry('sendVoice', form, { multipart: true }) }
516
- if (file.kind === 'audio') { form.append('audio', blob, name); return this.sendWithRetry('sendAudio', form, { multipart: true }) }
517
- if (file.kind === 'video') { form.append('video', blob, name); return this.sendWithRetry('sendVideo', form, { multipart: true }) }
518
- form.append('document', blob, name)
519
- 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
+ }
520
558
  }
521
559
 
522
560
  formatOutgoingText(text, payload = {}) {
@@ -547,19 +585,41 @@ export class TelegramAdapter {
547
585
  try {
548
586
  await this.sendWithRetry('sendMessage', params)
549
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
+ }
550
595
  if (!parseMode) throw err
551
596
  this.logger?.warn?.(`telegram HTML send failed, fallback plain: ${err.message}`)
552
- await this.call('sendMessage', {
553
- chat_id: chatId,
554
- text: plainChunks[i] ?? chunks[i],
555
- reply_to_message_id: replyTo,
556
- reply_markup: i === 0 ? effectiveMarkup : undefined,
557
- ...telegramThreadParams(threadId),
558
- })
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
+ }
559
618
  }
560
619
  }
561
620
  }
562
621
 
622
+
563
623
  async sendReply(chatId, replyTo, payload, threadId = 0) {
564
624
  const body = typeof payload === 'string' ? { text: payload } : (payload || {})
565
625
  const files = Array.isArray(body.files) ? body.files : []
@@ -579,6 +639,46 @@ export class TelegramAdapter {
579
639
  return this.sendReply(chatId, undefined, payload, normalizeThreadId(opts.threadId))
580
640
  }
581
641
 
642
+ async probeHealth(timeoutMs = 10000) {
643
+ if (!this.token) {
644
+ return { ok: false, error: 'Telegram bot token is empty' }
645
+ }
646
+ const start = Date.now()
647
+ try {
648
+ const res = await fetch(`${API}/bot${this.token}/getMe`, {
649
+ method: 'POST',
650
+ headers: { 'Content-Type': 'application/json' },
651
+ body: JSON.stringify({}),
652
+ signal: AbortSignal.timeout(Math.max(1000, Number(timeoutMs) || 10000)),
653
+ })
654
+ const latencyMs = Date.now() - start
655
+ const json = await res.json().catch(() => ({}))
656
+ if (!res.ok || json.ok === false) {
657
+ return {
658
+ ok: false,
659
+ latencyMs,
660
+ error: json.description || `HTTP ${res.status}`,
661
+ }
662
+ }
663
+ const me = json.result || {}
664
+ this.botId = Number(me.id) || this.botId
665
+ this.botUsername = String(me.username || this.botUsername)
666
+ return {
667
+ ok: true,
668
+ latencyMs,
669
+ botId: this.botId,
670
+ botUsername: this.botUsername,
671
+ firstName: me.first_name || '',
672
+ }
673
+ } catch (err) {
674
+ return {
675
+ ok: false,
676
+ latencyMs: Date.now() - start,
677
+ error: err instanceof Error ? err.message : String(err),
678
+ }
679
+ }
680
+ }
681
+
582
682
  async createForumTopic(chatId, name, opts = {}) {
583
683
  return this.call('createForumTopic', {
584
684
  chat_id: chatId,