@goodandready/dsh-messenger-gateway 0.3.22 → 0.4.1

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/http.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+
1
3
  export async function readBody(req) {
2
4
  const chunks = []
3
5
  for await (const chunk of req) chunks.push(chunk)
@@ -15,4 +17,13 @@ export function isTrustedSettingsRequest(req) {
15
17
  const host = String(req.headers?.host || '')
16
18
  if (!origin || !host) return false
17
19
  try { return new URL(origin).host === host } catch { return false }
18
- }
20
+ }
21
+
22
+ export function timingSafeCompare(a, b) {
23
+ if (typeof a !== 'string' || typeof b !== 'string') return false
24
+ const bufA = Buffer.from(a, 'utf8')
25
+ const bufB = Buffer.from(b, 'utf8')
26
+ if (bufA.length !== bufB.length) return false
27
+ return timingSafeEqual(bufA, bufB)
28
+ }
29
+
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { join, dirname, resolve } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
5
5
  import { Gateway } from './gateway.js'
6
- import { readBody, writeJson, isTrustedSettingsRequest } from './http.js'
6
+ import { readBody, writeJson, isTrustedSettingsRequest, timingSafeCompare } from './http.js'
7
7
  import { registerPluginUpdater } from './updater.js'
8
8
  import {
9
9
  createMessengerService, dispatchMessenger, httpStatusForError,
@@ -12,7 +12,7 @@ import {
12
12
  import { listHomes } from './homes.js'
13
13
  import { Config } from './config.js'
14
14
 
15
- export const name = 'dsh-messenger-gateway'
15
+ export const name = '@goodandready/dsh-messenger-gateway'
16
16
  export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments', 'tools']
17
17
 
18
18
  export const SETTINGS_NAMESPACE = 'dsh-messenger-gateway'
@@ -327,6 +327,11 @@ export function apply(ctx, config) {
327
327
  startedAt: gw?.stats?.startedAt || 0,
328
328
  scheduledCount,
329
329
  },
330
+ apiHealth: {
331
+ degraded: (gw?.consecutiveApiFailures || 0) >= 3,
332
+ consecutiveFailures: gw?.consecutiveApiFailures || 0,
333
+ lastError: gw?.lastApiError || null,
334
+ },
330
335
  config: publicConfig(source()),
331
336
  })
332
337
  },
@@ -496,9 +501,12 @@ export function apply(ctx, config) {
496
501
  const cfg = source().telegram || {}
497
502
  if (cfg.transport !== 'webhook') return writeJson(res, 404, { ok: false, error: 'webhook transport disabled' })
498
503
  const secret = String(cfg.webhookSecret || '').trim()
499
- if (secret) {
500
- const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
501
- if (hdr !== secret) return writeJson(res, 403, { ok: false, error: 'bad secret' })
504
+ if (!secret) {
505
+ return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
506
+ }
507
+ const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
508
+ if (!timingSafeCompare(hdr, secret)) {
509
+ return writeJson(res, 403, { ok: false, error: 'bad secret' })
502
510
  }
503
511
  const gw = getGw()
504
512
  const adapter = gw?.getAdapter?.('telegram')
@@ -37,4 +37,4 @@ export async function speakText(baseUrl, text, signal) {
37
37
  mime: data.mime || 'audio/mpeg',
38
38
  provider: data.provider,
39
39
  }
40
- }
40
+ }
package/lib/media.js CHANGED
@@ -40,14 +40,6 @@ export function classifyDocument(ext, mime) {
40
40
  return 'unsupported'
41
41
  }
42
42
 
43
- export function mediaKindOf(path) {
44
- const ext = extname(path).toLowerCase()
45
- if (IMAGE_EXT_TO_MIME[ext]) return 'photo'
46
- if (['.ogg', '.opus', '.oga'].includes(ext)) return 'voice'
47
- if (['.mp3', '.m4a', '.wav', '.flac'].includes(ext)) return 'audio'
48
- return 'document'
49
- }
50
-
51
43
  export function safeName(name) {
52
44
  return String(name || 'file').replace(/[^\w.\- ]/g, '_').slice(0, 120) || 'file'
53
45
  }
@@ -65,4 +57,4 @@ export function cacheName(prefix, ext, name) {
65
57
  return display ? `${base}-${display}${ext || ''}` : `${base}${ext || ''}`
66
58
  }
67
59
 
68
- export { basename }
60
+ export { basename }
package/lib/models.js CHANGED
@@ -20,11 +20,14 @@ export async function listModelCatalog(ctx, fallback = {}) {
20
20
  pId,
21
21
  (models || []).map((m) => ({ id: m.id || m, name: m.name || m.id || m }))
22
22
  )
23
- } catch {
23
+ } catch (err) {
24
+ /* safe fallback to empty list when provider cannot list models */
24
25
  result.modelsByProvider.set(pId, [])
25
26
  }
26
27
  }
27
- } catch {}
28
+ } catch (err) {
29
+ /* safe fallback when ctx.llm.listProviders is unavailable or throws */
30
+ }
28
31
  }
29
32
 
30
33
  // 2. If no providers from ctx.llm, check settings / fallback
@@ -117,4 +120,4 @@ export function buildModelsKeyboard(providerId, models, currentModel, page = 0,
117
120
  rows.push([{ text: '🔙 Back to providers', callback_data: 'mdl:back' }])
118
121
 
119
122
  return { inline_keyboard: rows, page: curPage, totalPages }
120
- }
123
+ }
package/lib/outbound.js CHANGED
@@ -110,4 +110,4 @@ export async function buildOutboundFiles(ctx, baseUrl, collector, opts = {}) {
110
110
  }
111
111
  }
112
112
  return files
113
- }
113
+ }
package/lib/pairing.js CHANGED
@@ -19,25 +19,29 @@ function writeJsonAtomicSync(filePath, data) {
19
19
  writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
20
20
  renameSync(tmpPath, filePath)
21
21
  } catch (err) {
22
- try { unlinkSync(tmpPath) } catch {}
22
+ try { unlinkSync(tmpPath) } catch { /* safe best-effort temp file cleanup */ }
23
23
  throw err
24
24
  }
25
25
  }
26
26
 
27
- export function createPairingStore(filePath) {
27
+ export function createPairingStore(filePath, { logger } = {}) {
28
28
  const state = { pending: {}, /** @type {number[]} */ approved: [] }
29
29
  if (filePath && existsSync(filePath)) {
30
30
  try {
31
31
  const raw = JSON.parse(readFileSync(filePath, 'utf8'))
32
32
  if (Array.isArray(raw.approved)) state.approved = raw.approved.map(Number).filter(Number.isFinite)
33
33
  if (raw.pending && typeof raw.pending === 'object') state.pending = raw.pending
34
- } catch {}
34
+ } catch (err) {
35
+ logger?.warn?.('[dsh-messenger-gateway] pairing store read error:', err?.message || err)
36
+ }
35
37
  }
36
38
  const persist = () => {
37
39
  if (!filePath) return
38
40
  try {
39
41
  writeJsonAtomicSync(filePath, { approved: state.approved, pending: state.pending })
40
- } catch {}
42
+ } catch (err) {
43
+ logger?.warn?.('[dsh-messenger-gateway] pairing store persist error:', err?.message || err)
44
+ }
41
45
  }
42
46
  const prune = () => {
43
47
  const now = Date.now()
package/lib/personas.js CHANGED
@@ -70,7 +70,7 @@ export function listPersonas() {
70
70
  return Object.values(BUILTIN_PERSONAS)
71
71
  }
72
72
 
73
- export function createPersonaStore(filePath) {
73
+ export function createPersonaStore(filePath, { logger } = {}) {
74
74
  let cache = { personas: {}, presets: {} }
75
75
  try {
76
76
  const raw = JSON.parse(readFileSync(filePath, 'utf8'))
@@ -81,13 +81,17 @@ export function createPersonaStore(filePath) {
81
81
  cache = { personas: raw, presets: {} }
82
82
  }
83
83
  }
84
- } catch {}
84
+ } catch (err) {
85
+ if (err?.code !== 'ENOENT') logger?.warn?.('[dsh-messenger-gateway] personas load error:', err?.message || err)
86
+ }
85
87
 
86
88
  function save() {
87
89
  try {
88
90
  mkdirSync(dirname(filePath), { recursive: true })
89
91
  writeFileSync(filePath, JSON.stringify(cache, null, 2), 'utf8')
90
- } catch {}
92
+ } catch (err) {
93
+ logger?.warn?.('[dsh-messenger-gateway] personas save error:', err?.message || err)
94
+ }
91
95
  }
92
96
 
93
97
  function makeKey(chatId, threadId = 0) {
@@ -148,4 +152,4 @@ export function createPersonaStore(filePath) {
148
152
  return { ...cache }
149
153
  },
150
154
  }
151
- }
155
+ }
package/lib/photos.js CHANGED
@@ -28,4 +28,4 @@ export function photoOnlyHint(attachments, userText) {
28
28
  if (!photos.length) return ''
29
29
  if (photos.length === 1) return '[User attached a photo]'
30
30
  return `[User attached ${photos.length} photos]`
31
- }
31
+ }
package/lib/scheduler.js CHANGED
@@ -33,7 +33,8 @@ export function formatRemaining(ms, locale = 'en') {
33
33
  return remMin ? `${hr}h ${remMin}m` : `${hr}h`
34
34
  }
35
35
 
36
- export function createScheduler(filePath, onDue) {
36
+ export function createScheduler(filePath, onDue, options = {}) {
37
+ const logger = options?.logger || console
37
38
  let tasks = []
38
39
  let loaded = false
39
40
  let timer = null
@@ -42,7 +43,10 @@ export function createScheduler(filePath, onDue) {
42
43
  try {
43
44
  const raw = await readFile(filePath, 'utf8')
44
45
  tasks = JSON.parse(raw)
45
- } catch {
46
+ } catch (err) {
47
+ if (err?.code !== 'ENOENT') {
48
+ logger?.debug?.('[dsh-messenger-gateway] scheduler load error:', err?.message || err)
49
+ }
46
50
  tasks = []
47
51
  }
48
52
  loaded = true
@@ -60,7 +64,9 @@ export function createScheduler(filePath, onDue) {
60
64
  return (now - finishTime) < RETENTION_MS
61
65
  })
62
66
  await writeJsonAtomic(filePath, tasks)
63
- } catch {}
67
+ } catch (err) {
68
+ logger?.warn?.('[dsh-messenger-gateway] scheduler save failed:', err?.message || err)
69
+ }
64
70
  }
65
71
 
66
72
  async function checkDue() {
@@ -77,7 +83,11 @@ export function createScheduler(filePath, onDue) {
77
83
  task.firedAt = now
78
84
  }
79
85
  if (typeof onDue === 'function') {
80
- try { await onDue(task) } catch {}
86
+ try {
87
+ await onDue(task)
88
+ } catch (err) {
89
+ logger?.warn?.('[dsh-messenger-gateway] scheduler task execution failed:', err?.message || err)
90
+ }
81
91
  }
82
92
  }
83
93
 
package/lib/stream.js CHANGED
@@ -1,116 +1,116 @@
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, locale = 'en') {
15
- if (!toolName) {
16
- return String(locale || '').toLowerCase().startsWith('zh') ? '⏳ 思考中…' : '⏳ Thinking…'
17
- }
18
- return `🔧 ${toolName}…`
19
- }
20
-
21
- export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
22
- const body = String(streamText || '').trimEnd()
23
- const head = toolName ? `${formatProgressLine(toolName)}\n\n` : ''
24
- const combined = head + (body || '…')
25
- if (combined.length <= maxLen) return combined
26
- return combined.slice(0, maxLen - 1) + '…'
27
- }
28
-
29
- export function parseRetryAfter(err) {
30
- if (!err) return 0
31
- const msg = err.message || String(err)
32
- const match = /retry after (\d+)/i.exec(msg)
33
- if (match) return parseInt(match[1], 10) * 1000
34
- return 0
35
- }
36
-
37
- export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs = 250) {
38
- let pending = null
39
- let timer = null
40
- let lastSent = ''
41
- let inFlight = Promise.resolve()
42
- let retryDelayMs = 0
43
-
44
- const flush = () => {
45
- timer = null
46
- if (pending === null || pending === lastSent) return
47
- const text = pending
48
- inFlight = inFlight.then(async () => {
49
- try {
50
- if (retryDelayMs > 0) {
51
- const delay = retryDelayMs
52
- retryDelayMs = 0
53
- await new Promise((r) => setTimeout(r, delay))
54
- }
55
- await editFn(text)
56
- lastSent = text
57
- } catch (err) {
58
- const retry = parseRetryAfter(err)
59
- if (retry > 0) {
60
- retryDelayMs = retry
61
- // Re-schedule flush with retry delay
62
- if (!timer) {
63
- timer = setTimeout(flush, retry + 100)
64
- timer.unref?.()
65
- }
66
- }
67
- }
68
- })
69
- }
70
-
71
- return {
72
- push(text) {
73
- pending = text
74
- if (timer) return
75
- // Fast first chunk: when lastSent is empty, dispatch quickly so user sees immediate response.
76
- // Subsequent edits throttle to intervalMs to respect Telegram rate limits.
77
- const delay = !lastSent ? Math.min(firstChunkDelayMs, intervalMs) : Math.max(200, intervalMs)
78
- timer = setTimeout(flush, delay)
79
- timer.unref?.()
80
- },
81
- async flush() {
82
- if (timer) { clearTimeout(timer); timer = null }
83
- flush()
84
- await inFlight
85
- // If there's still a pending difference (e.g. rate limit delay happened), wait and retry
86
- if (pending !== null && pending !== lastSent) {
87
- if (retryDelayMs > 0) {
88
- await new Promise((r) => setTimeout(r, retryDelayMs + 50))
89
- retryDelayMs = 0
90
- }
91
- try {
92
- await editFn(pending)
93
- lastSent = pending
94
- } catch {}
95
- }
96
- },
97
- }
98
- }
99
-
100
- export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
101
- if (typeof typingFn !== 'function') return () => {}
102
- let active = true
103
- const safeInvoke = () => {
104
- if (!active) return
105
- try {
106
- typingFn()?.catch?.(() => {})
107
- } catch {}
108
- }
109
- safeInvoke()
110
- const timer = setInterval(safeInvoke, intervalMs)
111
- timer.unref?.()
112
- return () => {
113
- active = false
114
- clearInterval(timer)
115
- }
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, locale = 'en') {
15
+ if (!toolName) {
16
+ return String(locale || '').toLowerCase().startsWith('zh') ? '⏳ 思考中…' : '⏳ Thinking…'
17
+ }
18
+ return `🔧 ${toolName}…`
19
+ }
20
+
21
+ export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
22
+ const body = String(streamText || '').trimEnd()
23
+ const head = toolName ? `${formatProgressLine(toolName)}\n\n` : ''
24
+ const combined = head + (body || '…')
25
+ if (combined.length <= maxLen) return combined
26
+ return combined.slice(0, maxLen - 1) + '…'
27
+ }
28
+
29
+ export function parseRetryAfter(err) {
30
+ if (!err) return 0
31
+ const msg = err.message || String(err)
32
+ const match = /retry after (\d+)/i.exec(msg)
33
+ if (match) return parseInt(match[1], 10) * 1000
34
+ return 0
35
+ }
36
+
37
+ export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs = 250) {
38
+ let pending = null
39
+ let timer = null
40
+ let lastSent = ''
41
+ let inFlight = Promise.resolve()
42
+ let retryDelayMs = 0
43
+
44
+ const flush = () => {
45
+ timer = null
46
+ if (pending === null || pending === lastSent) return
47
+ const text = pending
48
+ inFlight = inFlight.then(async () => {
49
+ try {
50
+ if (retryDelayMs > 0) {
51
+ const delay = retryDelayMs
52
+ retryDelayMs = 0
53
+ await new Promise((r) => setTimeout(r, delay))
54
+ }
55
+ await editFn(text)
56
+ lastSent = text
57
+ } catch (err) {
58
+ const retry = parseRetryAfter(err)
59
+ if (retry > 0) {
60
+ retryDelayMs = retry
61
+ // Re-schedule flush with retry delay
62
+ if (!timer) {
63
+ timer = setTimeout(flush, retry + 100)
64
+ timer.unref?.()
65
+ }
66
+ }
67
+ }
68
+ })
69
+ }
70
+
71
+ return {
72
+ push(text) {
73
+ pending = text
74
+ if (timer) return
75
+ // Fast first chunk: when lastSent is empty, dispatch quickly so user sees immediate response.
76
+ // Subsequent edits throttle to intervalMs to respect Telegram rate limits.
77
+ const delay = !lastSent ? Math.min(firstChunkDelayMs, intervalMs) : Math.max(200, intervalMs)
78
+ timer = setTimeout(flush, delay)
79
+ timer.unref?.()
80
+ },
81
+ async flush() {
82
+ if (timer) { clearTimeout(timer); timer = null }
83
+ flush()
84
+ await inFlight
85
+ // If there's still a pending difference (e.g. rate limit delay happened), wait and retry
86
+ if (pending !== null && pending !== lastSent) {
87
+ if (retryDelayMs > 0) {
88
+ await new Promise((r) => setTimeout(r, retryDelayMs + 50))
89
+ retryDelayMs = 0
90
+ }
91
+ try {
92
+ await editFn(pending)
93
+ lastSent = pending
94
+ } catch { /* safe best-effort final stream update delivery */ }
95
+ }
96
+ },
97
+ }
98
+ }
99
+
100
+ export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
101
+ if (typeof typingFn !== 'function') return () => {}
102
+ let active = true
103
+ const safeInvoke = () => {
104
+ if (!active) return
105
+ try {
106
+ typingFn()?.catch?.(() => { /* safe best-effort typing indicator */ })
107
+ } catch { /* safe best-effort typing indicator */ }
108
+ }
109
+ safeInvoke()
110
+ const timer = setInterval(safeInvoke, intervalMs)
111
+ timer.unref?.()
112
+ return () => {
113
+ active = false
114
+ clearInterval(timer)
115
+ }
116
116
  }
package/lib/topics.js CHANGED
@@ -25,20 +25,6 @@ export function sessionKey({ platform, chatId, threadId = 0, userId, chatType, s
25
25
  return base
26
26
  }
27
27
 
28
- export function parseChatKey(key) {
29
- const parts = String(key || "").split(":")
30
- if (parts.length < 2) return null
31
- const platform = parts[0]
32
- const chatId = parts[1]
33
- const threadId = parts.length >= 3 ? normalizeThreadId(parts[2]) : 0
34
- const out = { platform, chatId, threadId }
35
- if (parts.length >= 5 && parts[3] === "u") {
36
- const userId = Number(parts[4])
37
- if (Number.isFinite(userId)) out.userId = userId
38
- }
39
- return out
40
- }
41
-
42
28
  /** Extra Telegram API fields for forum topic replies. */
43
29
  export function telegramThreadParams(threadId) {
44
30
  const tid = normalizeThreadId(threadId)
package/lib/tts.js CHANGED
@@ -57,16 +57,6 @@ export function isOggOpusMime(mime) {
57
57
  return m.includes('ogg') || m.includes('opus')
58
58
  }
59
59
 
60
- export function voiceReplyFile(spoken) {
61
- const mime = spoken?.mime || 'audio/mpeg'
62
- return {
63
- bytes: spoken.audio,
64
- mime,
65
- kind: 'voice',
66
- name: voiceFileNameForMime(mime),
67
- }
68
- }
69
-
70
60
  function runFfmpeg(args) {
71
61
  return new Promise((resolve, reject) => {
72
62
  const child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] })
@@ -10,25 +10,29 @@ function writeJsonAtomicSync(filePath, data) {
10
10
  writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8")
11
11
  renameSync(tmpPath, filePath)
12
12
  } catch (err) {
13
- try { unlinkSync(tmpPath) } catch {}
13
+ try { unlinkSync(tmpPath) } catch { /* safe best-effort temp file cleanup */ }
14
14
  throw err
15
15
  }
16
16
  }
17
17
 
18
- export function createVoicePrefs(filePath) {
18
+ export function createVoicePrefs(filePath, { logger } = {}) {
19
19
  /** @type {Record<string, boolean>} */
20
20
  let state = {}
21
21
  if (filePath && existsSync(filePath)) {
22
22
  try {
23
23
  const raw = JSON.parse(readFileSync(filePath, "utf8"))
24
24
  if (raw && typeof raw === "object") state = raw
25
- } catch {}
25
+ } catch (err) {
26
+ logger?.warn?.('[dsh-messenger-gateway] voice prefs read error:', err?.message || err)
27
+ }
26
28
  }
27
29
  const persist = () => {
28
30
  if (!filePath) return
29
31
  try {
30
32
  writeJsonAtomicSync(filePath, state)
31
- } catch {}
33
+ } catch (err) {
34
+ logger?.warn?.('[dsh-messenger-gateway] voice prefs persist error:', err?.message || err)
35
+ }
32
36
  }
33
37
  const key = (userId) => String(Number(userId) || userId || "")
34
38
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-messenger-gateway",
3
- "version": "0.3.22",
3
+ "version": "0.4.1",
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",
@@ -42,7 +42,11 @@
42
42
  },
43
43
  "client": {
44
44
  "platform": "web",
45
- "inject": []
45
+ "inject": [
46
+ "@deepseek-ai/dsh-client-locale",
47
+ "@deepseek-ai/dsh-client-ui-slots",
48
+ "@deepseek-ai/dsh-client-ui-settings"
49
+ ]
46
50
  }
47
51
  },
48
52
  "peerDependencies": {