@goodandready/dsh-messenger-gateway 0.4.10 → 0.4.11

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/ask.js CHANGED
@@ -56,7 +56,9 @@ export function buildInlineKeyboard(token, buttons) {
56
56
 
57
57
  export function normalizeAskOptions(options = []) {
58
58
  if (Array.isArray(options)) {
59
- const flat = options.flat ? options.flat() : [].concat(...options)
59
+ const flat = typeof options.flat === 'function'
60
+ ? options.flat()
61
+ : options.reduce((acc, item) => acc.concat(item), [])
60
62
  return flat.map((opt, i) => {
61
63
  if (typeof opt === 'string') return { id: String(i + 1), text: opt, selected: false }
62
64
  const id = String(opt?.id ?? opt?.buttonId ?? i + 1).trim()
package/lib/documents.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { open } from 'node:fs/promises'
2
2
  import { extname } from 'node:path'
3
3
  import { inflateRawSync } from 'node:zlib'
4
4
 
@@ -132,7 +132,36 @@ export async function parseDocument(filePath, opts = {}) {
132
132
  const ext = extname(filePath).toLowerCase()
133
133
 
134
134
  try {
135
- const rawBuffer = await readFile(filePath)
135
+ const handle = await open(filePath, 'r')
136
+ let rawBuffer
137
+ let truncated = false
138
+ let originalLength = 0
139
+
140
+ try {
141
+ const st = await handle.stat()
142
+ originalLength = st.size
143
+
144
+ if (TEXT_INJECT_EXTS.has(ext)) {
145
+ const toRead = Math.min(st.size, maxBytes)
146
+ const buf = Buffer.alloc(toRead)
147
+ const { bytesRead } = await handle.read(buf, 0, toRead, 0)
148
+ rawBuffer = buf.subarray(0, bytesRead)
149
+ truncated = st.size > toRead
150
+ } else if (ext === '.pdf' || ext === '.docx') {
151
+ const maxBinary = Math.max(maxBytes * 4, 10 * 1024 * 1024)
152
+ if (st.size > maxBinary) {
153
+ return { parsed: false, error: `File size (${st.size} bytes) exceeds safety parse limit` }
154
+ }
155
+ const buf = Buffer.alloc(st.size)
156
+ const { bytesRead } = await handle.read(buf, 0, st.size, 0)
157
+ rawBuffer = buf.subarray(0, bytesRead)
158
+ } else {
159
+ return { parsed: false, type: 'unsupported' }
160
+ }
161
+ } finally {
162
+ await handle.close()
163
+ }
164
+
136
165
  let text = ''
137
166
  let parsedType = 'text'
138
167
 
@@ -145,15 +174,11 @@ export async function parseDocument(filePath, opts = {}) {
145
174
  } else if (TEXT_INJECT_EXTS.has(ext)) {
146
175
  parsedType = ext.slice(1)
147
176
  text = rawBuffer.toString('utf8')
148
- } else {
149
- return { parsed: false, type: 'unsupported' }
150
177
  }
151
178
 
152
179
  text = text.trim()
153
180
  if (!text) return { parsed: false, type: parsedType }
154
181
 
155
- const originalLength = text.length
156
- let truncated = false
157
182
  if (text.length > maxBytes) {
158
183
  text = text.slice(0, maxBytes)
159
184
  truncated = true
@@ -164,9 +189,10 @@ export async function parseDocument(filePath, opts = {}) {
164
189
  type: parsedType,
165
190
  text,
166
191
  truncated,
167
- originalLength,
192
+ originalLength: originalLength || text.length,
168
193
  }
169
194
  } catch (err) {
170
195
  return { parsed: false, error: err.message }
171
196
  }
172
197
  }
198
+
@@ -110,7 +110,29 @@ export function normalizeFiles(files) {
110
110
  err.status = 400
111
111
  throw err
112
112
  }
113
- if (file.bytes) return file
113
+ if (file.bytes !== undefined) {
114
+ let buf = null
115
+ if (Buffer.isBuffer(file.bytes) || file.bytes instanceof Uint8Array) {
116
+ buf = Buffer.from(file.bytes)
117
+ } else if (Array.isArray(file.bytes)) {
118
+ buf = Buffer.from(file.bytes)
119
+ } else if (file.bytes && typeof file.bytes === 'object' && file.bytes.type === 'Buffer' && Array.isArray(file.bytes.data)) {
120
+ buf = Buffer.from(file.bytes.data)
121
+ } else if (typeof file.bytes === 'string') {
122
+ buf = Buffer.from(file.bytes, 'utf8')
123
+ }
124
+ if (!buf) {
125
+ const err = new Error(`files[${i}].bytes must be a Buffer, Uint8Array, or array of bytes`)
126
+ err.status = 400
127
+ throw err
128
+ }
129
+ return {
130
+ bytes: buf,
131
+ mime: file.mime || 'application/octet-stream',
132
+ kind: file.kind || 'document',
133
+ name: file.name || 'file',
134
+ }
135
+ }
114
136
  if (typeof file.dataBase64 === 'string') {
115
137
  return {
116
138
  bytes: Buffer.from(file.dataBase64, 'base64'),
package/lib/outbound.js CHANGED
@@ -72,12 +72,54 @@ export async function readAttachmentBytes(ctx, ref) {
72
72
  }
73
73
  }
74
74
 
75
- export async function fetchInternalImage(baseUrl, path, signal) {
75
+ export async function fetchInternalImage(baseUrl, path, signal, opts = {}) {
76
+ const { timeoutMs = 15_000, maxBytes = 20 * 1024 * 1024 } = opts
77
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
78
+ const effectiveSignal = signal
79
+ ? (typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal)
80
+ : timeoutSignal
81
+
76
82
  const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
77
- const res = await fetch(url, { signal })
83
+ const res = await fetch(url, { signal: effectiveSignal })
78
84
  if (!res.ok) throw new Error(`image fetch HTTP ${res.status}`)
79
- const bytes = new Uint8Array(await res.arrayBuffer())
80
- const mime = res.headers.get('content-type') || 'image/png'
85
+
86
+ const clHeader = res.headers?.get?.('content-length')
87
+ const cl = Number(clHeader)
88
+ if (Number.isFinite(cl) && cl > maxBytes) {
89
+ throw new Error(`image size (${cl} bytes) exceeds limit of ${maxBytes} bytes`)
90
+ }
91
+
92
+ let bytes
93
+ if (res.body && typeof res.body.getReader === 'function') {
94
+ const reader = res.body.getReader()
95
+ const chunks = []
96
+ let total = 0
97
+ while (true) {
98
+ const { done, value } = await reader.read()
99
+ if (done) break
100
+ total += value.length
101
+ if (total > maxBytes) {
102
+ reader.cancel().catch(() => {})
103
+ throw new Error(`image size exceeds limit of ${maxBytes} bytes`)
104
+ }
105
+ chunks.push(value)
106
+ }
107
+ const combined = new Uint8Array(total)
108
+ let offset = 0
109
+ for (const chunk of chunks) {
110
+ combined.set(chunk, offset)
111
+ offset += chunk.length
112
+ }
113
+ bytes = combined
114
+ } else {
115
+ const ab = await res.arrayBuffer()
116
+ if (ab.byteLength > maxBytes) {
117
+ throw new Error(`image size exceeds limit of ${maxBytes} bytes`)
118
+ }
119
+ bytes = new Uint8Array(ab)
120
+ }
121
+
122
+ const mime = res.headers?.get?.('content-type') || 'image/png'
81
123
  const fromQuery = url.searchParams.get('id') || url.searchParams.get('name')
82
124
  const name = fromQuery ? `image-${fromQuery.slice(0, 32)}.png` : 'image.png'
83
125
  return { bytes, mime, kind: 'photo', name }
@@ -87,7 +129,7 @@ const DEFAULT_MAX_OUTBOUND_FILES = 10
87
129
 
88
130
  /** Build Telegram-ready files from assistant image blocks and inline plugin URLs. */
89
131
  export async function buildOutboundFiles(ctx, baseUrl, collector, opts = {}) {
90
- const { signal, logger, maxFiles = DEFAULT_MAX_OUTBOUND_FILES } = opts
132
+ const { signal, logger, maxFiles = DEFAULT_MAX_OUTBOUND_FILES, maxImageBytes, imageTimeoutMs } = opts
91
133
  const files = []
92
134
  const refs = dedupeAttachmentRefs(collector.images)
93
135
  for (const ref of refs) {
@@ -104,7 +146,10 @@ export async function buildOutboundFiles(ctx, baseUrl, collector, opts = {}) {
104
146
  if (files.length >= maxFiles) break
105
147
  if (signal?.aborted) break
106
148
  try {
107
- files.push(await fetchInternalImage(baseUrl, path, signal))
149
+ files.push(await fetchInternalImage(baseUrl, path, signal, {
150
+ maxBytes: maxImageBytes,
151
+ timeoutMs: imageTimeoutMs,
152
+ }))
108
153
  } catch (e) {
109
154
  logger?.warn?.(`outbound url image: ${e.message}`)
110
155
  }
package/lib/personas.js CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
2
- import { dirname } from 'node:path'
1
+ import { readFileSync } from 'node:fs'
2
+ import { writeJsonAtomicSync } from './storage-atomic.js'
3
3
 
4
4
  export const BUILTIN_PERSONAS = {
5
5
  default: {
@@ -87,8 +87,7 @@ export function createPersonaStore(filePath, { logger } = {}) {
87
87
 
88
88
  function save() {
89
89
  try {
90
- mkdirSync(dirname(filePath), { recursive: true })
91
- writeFileSync(filePath, JSON.stringify(cache, null, 2), 'utf8')
90
+ writeJsonAtomicSync(filePath, cache)
92
91
  } catch (err) {
93
92
  logger?.warn?.('[dsh-messenger-gateway] personas save error:', err?.message || err)
94
93
  }
package/lib/scheduler.js CHANGED
@@ -42,10 +42,16 @@ export function createScheduler(filePath, onDue, options = {}) {
42
42
  async function load() {
43
43
  try {
44
44
  const raw = await readFile(filePath, 'utf8')
45
- tasks = JSON.parse(raw)
45
+ const parsed = JSON.parse(raw)
46
+ if (Array.isArray(parsed)) {
47
+ tasks = parsed
48
+ } else {
49
+ logger?.warn?.(`[dsh-messenger-gateway] scheduler invalid tasks payload in ${filePath}: expected array, got ${typeof parsed}; resetting to empty list`)
50
+ tasks = []
51
+ }
46
52
  } catch (err) {
47
53
  if (err?.code !== 'ENOENT') {
48
- logger?.debug?.('[dsh-messenger-gateway] scheduler load error:', err?.message || err)
54
+ logger?.warn?.('[dsh-messenger-gateway] scheduler load error:', err?.message || err)
49
55
  }
50
56
  tasks = []
51
57
  }
@@ -56,6 +62,7 @@ export function createScheduler(filePath, onDue, options = {}) {
56
62
 
57
63
  async function save() {
58
64
  if (!filePath) return
65
+ if (!Array.isArray(tasks)) tasks = []
59
66
  try {
60
67
  const now = Date.now()
61
68
  tasks = tasks.filter((t) => {
@@ -71,6 +78,7 @@ export function createScheduler(filePath, onDue, options = {}) {
71
78
 
72
79
  async function checkDue() {
73
80
  if (!loaded) await load()
81
+ if (!Array.isArray(tasks)) tasks = []
74
82
  const now = Date.now()
75
83
  const due = tasks.filter((t) => t.status === 'pending' && t.dueAt <= now)
76
84
 
@@ -99,10 +107,10 @@ export function createScheduler(filePath, onDue, options = {}) {
99
107
  function start(intervalMs = 5000) {
100
108
  if (timer) clearInterval(timer)
101
109
  timer = setInterval(() => {
102
- checkDue().catch((err) => { logger?.debug?.('[dsh-messenger-gateway] scheduler checkDue error:', err?.message || err) })
110
+ checkDue().catch((err) => { logger?.warn?.('[dsh-messenger-gateway] scheduler checkDue error:', err?.message || err) })
103
111
  }, intervalMs)
104
112
  timer.unref?.()
105
- checkDue().catch((err) => { logger?.debug?.('[dsh-messenger-gateway] scheduler checkDue error:', err?.message || err) })
113
+ checkDue().catch((err) => { logger?.warn?.('[dsh-messenger-gateway] scheduler checkDue error:', err?.message || err) })
106
114
  }
107
115
 
108
116
  function stop() {
@@ -78,7 +78,7 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
78
78
  source = () => currentConfig
79
79
 
80
80
  const persist = async (next) => {
81
- const payload = Config(next)
81
+ const payload = resolveConfig(next)
82
82
  if (typeof service.replace === 'function') {
83
83
  await service.replace(namespace, payload, getRevision())
84
84
  } else if (typeof service.update === 'function') {
@@ -88,7 +88,7 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
88
88
  } else {
89
89
  throw new Error('settings service cannot persist configuration')
90
90
  }
91
- currentConfig = resolveConfig(payload)
91
+ currentConfig = payload
92
92
  sync()
93
93
  for (const cb of watchers) {
94
94
  try { cb(currentConfig) } catch (err) { logger?.debug?.(`${namespace}: watcher error:`, err?.message || err) }
@@ -1,4 +1,5 @@
1
1
  import { writeFile, rename, mkdir, unlink } from 'node:fs/promises'
2
+ import { writeFileSync, renameSync, mkdirSync, unlinkSync } from 'node:fs'
2
3
  import { dirname } from 'node:path'
3
4
  import { randomUUID } from 'node:crypto'
4
5
 
@@ -18,4 +19,20 @@ export async function writeJsonAtomic(filePath, data) {
18
19
  await unlink(tmpPath).catch(() => {})
19
20
  throw err
20
21
  }
22
+ }
23
+
24
+ /**
25
+ * Synchronous atomic JSON write via temporary file and rename.
26
+ */
27
+ export function writeJsonAtomicSync(filePath, data) {
28
+ const dir = dirname(filePath)
29
+ mkdirSync(dir, { recursive: true })
30
+ const tmpPath = `${filePath}.${randomUUID().slice(0, 8)}.tmp`
31
+ try {
32
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
33
+ renameSync(tmpPath, filePath)
34
+ } catch (err) {
35
+ try { unlinkSync(tmpPath) } catch { /* ignore */ }
36
+ throw err
37
+ }
21
38
  }
package/lib/updater.js CHANGED
@@ -181,15 +181,20 @@ export async function getUpdateStatus(options, target = runtime()) {
181
181
  }
182
182
  }
183
183
 
184
+ export function buildUpdateArgs(target, packageSpec, options = {}) {
185
+ return [
186
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
187
+ packageSpec,
188
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
189
+ ]
190
+ }
191
+
184
192
  export async function installExact(target, packageSpec, options = {}) {
185
193
  if (target.cliEntry === undefined) throw new Error('Automatic update CLI entry is unavailable in this runtime.')
186
194
  return new Promise((resolvePromise, reject) => {
187
- const args = [
188
- target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
189
- '--config.minimumReleaseAge=0', packageSpec,
190
- `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
191
- ]
192
- const child = spawn(process.execPath, args, {
195
+ const args = buildUpdateArgs(target, packageSpec, options)
196
+ const spawnFn = options.spawn ?? spawn
197
+ const child = spawnFn(process.execPath, args, {
193
198
  cwd: target.profileDir,
194
199
  windowsHide: true,
195
200
  stdio: ['ignore', 'pipe', 'pipe'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-messenger-gateway",
3
- "version": "0.4.10",
3
+ "version": "0.4.11",
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",