@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/LICENSE +1 -1
- package/cordis.patch.yml +1 -1
- package/lib/adapters/discord.js +1 -1
- package/lib/adapters/slack.js +1 -1
- package/lib/adapters/telegram-inbound.js +141 -0
- package/lib/adapters/telegram.js +35 -151
- package/lib/alerts.js +42 -1
- package/lib/api-health.js +38 -0
- package/lib/artifacts.js +118 -118
- package/lib/ask.js +203 -152
- package/lib/client.js +1698 -1631
- package/lib/commands.js +52 -0
- package/lib/documents.js +1 -1
- package/lib/file-manager.js +27 -1
- package/lib/forum-mirror.js +105 -0
- package/lib/gateway-callbacks.js +187 -0
- package/lib/gateway-commands.js +584 -0
- package/lib/gateway-turn.js +285 -0
- package/lib/gateway.js +58 -1240
- package/lib/http.js +12 -1
- package/lib/index.js +13 -5
- package/lib/integrations.js +1 -1
- package/lib/media.js +1 -9
- package/lib/models.js +6 -3
- package/lib/outbound.js +1 -1
- package/lib/pairing.js +8 -4
- package/lib/personas.js +8 -4
- package/lib/photos.js +1 -1
- package/lib/scheduler.js +14 -4
- package/lib/stream.js +115 -115
- package/lib/topics.js +0 -14
- package/lib/tts.js +0 -10
- package/lib/voice-prefs.js +8 -4
- package/package.json +6 -2
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
|
-
|
|
501
|
-
|
|
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')
|
package/lib/integrations.js
CHANGED
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
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
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 {
|
|
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'] })
|
package/lib/voice-prefs.js
CHANGED
|
@@ -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
|
+
"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": {
|