@goodandready/dsh-messenger-gateway 0.4.10 → 0.4.12

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/client.js CHANGED
@@ -17,6 +17,15 @@ window.__ModuleLoader__.load({
17
17
  const ROW_ID = 'dsh-messenger-gateway'
18
18
  const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
19
19
 
20
+ function timeoutSignal(ms) {
21
+ if (typeof AbortSignal?.timeout === 'function') {
22
+ return AbortSignal.timeout(ms)
23
+ }
24
+ const controller = new AbortController()
25
+ setTimeout(() => controller.abort(new Error('TimeoutError')), ms)
26
+ return controller.signal
27
+ }
28
+
20
29
  const en = {
21
30
  title: 'Messenger gateway',
22
31
  description: 'Telegram bot: text, voice, photos, documents, and interactive asks.',
@@ -593,7 +602,7 @@ window.__ModuleLoader__.load({
593
602
 
594
603
  const loadStatus = React.useCallback(async () => {
595
604
  try {
596
- const res = await fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store' })
605
+ const res = await fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store', signal: timeoutSignal(10000) })
597
606
  const data = await res.json().catch(() => ({}))
598
607
  if (res.ok && data.ok) {
599
608
  setStatus(data)
@@ -605,7 +614,7 @@ window.__ModuleLoader__.load({
605
614
 
606
615
  const loadPairing = React.useCallback(async () => {
607
616
  try {
608
- const res = await fetch(`${ROUTE_PREFIX}/pairing`, { credentials: 'same-origin', cache: 'no-store' })
617
+ const res = await fetch(`${ROUTE_PREFIX}/pairing`, { credentials: 'same-origin', cache: 'no-store', signal: timeoutSignal(10000) })
609
618
  const data = await res.json().catch(() => ({}))
610
619
  if (res.ok && data.ok) setPending(Array.isArray(data.pending) ? data.pending : [])
611
620
  } catch (err) {
@@ -626,7 +635,7 @@ window.__ModuleLoader__.load({
626
635
  const loadUpdateStatus = React.useCallback(async () => {
627
636
  setUpdateState((s) => ({ ...s, checking: true, error: '' }))
628
637
  try {
629
- const res = await fetch(`${ROUTE_PREFIX}/update`, { cache: 'no-store' })
638
+ const res = await fetch(`${ROUTE_PREFIX}/update`, { cache: 'no-store', signal: timeoutSignal(15000) })
630
639
  const data = await res.json().catch(() => ({}))
631
640
  if (res.ok && data.ok) {
632
641
  setUpdateState((s) => ({
@@ -655,6 +664,7 @@ window.__ModuleLoader__.load({
655
664
  'Content-Type': 'application/json',
656
665
  'x-dsh-plugin-update': '1',
657
666
  },
667
+ signal: timeoutSignal(60000),
658
668
  })
659
669
  const data = await res.json().catch(() => ({}))
660
670
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
@@ -689,6 +699,7 @@ window.__ModuleLoader__.load({
689
699
  credentials: 'same-origin',
690
700
  headers: { 'Content-Type': 'application/json' },
691
701
  body: JSON.stringify({ timeoutMs: 8000 }),
702
+ signal: timeoutSignal(15000),
692
703
  })
693
704
  const data = await res.json().catch(() => ({}))
694
705
  if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
@@ -1348,6 +1359,7 @@ window.__ModuleLoader__.load({
1348
1359
  credentials: 'same-origin',
1349
1360
  headers: { 'Content-Type': 'application/json' },
1350
1361
  body: JSON.stringify({ code: row.code }),
1362
+ signal: timeoutSignal(15000),
1351
1363
  })
1352
1364
  const data = await res.json()
1353
1365
  if (!res.ok || !data.ok) throw new Error(data.error || res.status)
@@ -1376,6 +1388,7 @@ window.__ModuleLoader__.load({
1376
1388
  credentials: 'same-origin',
1377
1389
  headers: { 'Content-Type': 'application/json' },
1378
1390
  body: JSON.stringify({ code: row.code }),
1391
+ signal: timeoutSignal(15000),
1379
1392
  })
1380
1393
  const data = await res.json()
1381
1394
  if (!res.ok || !data.ok) throw new Error(data.error || res.status)
package/lib/config.js CHANGED
@@ -9,6 +9,7 @@ const HomeEntry = z.object({
9
9
  export const PluginConfig = z.object({
10
10
  enabled: z.boolean().volatile().default(true),
11
11
  internalBaseURL: z.string().volatile().default('http://127.0.0.1:3080'),
12
+ defaultLocale: z.union([z.const('en'), z.const('zh')]).default('en').description('Default language for bot messages and notifications (en, zh)'),
12
13
  telegram: z.object({
13
14
  enabled: z.boolean().default(false),
14
15
  botToken: z.string().role('secret').default(''),
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
+
package/lib/gateway.js CHANGED
@@ -182,40 +182,55 @@ export class Gateway {
182
182
  }
183
183
  }
184
184
 
185
- async start() {
186
- this.disposeListener = this.ctx.on('session/event', (session, event) => {
187
- if (this.config.telegram?.forumMirrorEnabled) {
188
- if (event.type === 'turn/start' || event.type === 'session/create') {
189
- this.mirrorSessionToForumTopic(session).catch?.((e) => this.logger?.debug?.('mirror error:', e?.message || e))
190
- } else if (event.type === 'turn/end') {
191
- this.relayTurnToForumMirror(session, event).catch?.((e) => this.logger?.debug?.('relay mirror error:', e?.message || e))
192
- }
185
+ handleHostEvent(session, event) {
186
+ if (!session || !event) return
187
+ if (this.config.telegram?.forumMirrorEnabled) {
188
+ if (event.type === 'turn/start' || event.type === 'session/create') {
189
+ this.mirrorSessionToForumTopic(session).catch?.((e) => this.logger?.debug?.('mirror error:', e?.message || e))
190
+ } else if (event.type === 'turn/end') {
191
+ this.relayTurnToForumMirror(session, event).catch?.((e) => this.logger?.debug?.('relay mirror error:', e?.message || e))
193
192
  }
193
+ }
194
194
 
195
- const collector = this.pending.get(session.id)
196
- if (!collector) return
197
- if (event.type === 'assistant/message') {
198
- const msg = event.data.message
199
- const text = assistantText(msg)
200
- if (text) collector.lastText = text
201
- const extra = collectAssistantParts(msg)
202
- for (const img of extra.images) collector.images.push(img)
203
- } else if (event.type === 'assistant/chunk') {
204
- const delta = extractTextDelta(event.data?.chunk)
205
- if (delta) {
206
- collector.streamText = (collector.streamText || '') + delta
207
- collector.onStream?.(collector.streamText, collector.toolName)
208
- }
209
- } else if (event.type === 'tool/call') {
210
- collector.toolName = extractToolName(event.data) || collector.toolName
211
- collector.onStream?.(collector.streamText || '', collector.toolName)
212
- } else if (event.type === 'tool/result') {
213
- collector.toolName = ''
214
- collector.onStream?.(collector.streamText || '', '')
215
- } else if (event.type === 'turn/end') {
216
- collector.reason = event.data.reason
195
+ const collector = this.pending.get(session.id)
196
+ if (!collector) return
197
+ if (event.type === 'assistant/message') {
198
+ const msg = event.data?.message || event.data
199
+ const text = assistantText(msg)
200
+ if (text) collector.lastText = text
201
+ const extra = collectAssistantParts(msg)
202
+ for (const img of extra.images) collector.images.push(img)
203
+ } else if (event.type === 'assistant/chunk') {
204
+ const delta = extractTextDelta(event.data?.chunk || event.data)
205
+ if (delta) {
206
+ collector.streamText = (collector.streamText || '') + delta
207
+ collector.onStream?.(collector.streamText, collector.toolName)
217
208
  }
218
- })
209
+ } else if (event.type === 'tool/call') {
210
+ collector.toolName = extractToolName(event.data) || collector.toolName
211
+ collector.onStream?.(collector.streamText || '', collector.toolName)
212
+ } else if (event.type === 'tool/result') {
213
+ collector.toolName = ''
214
+ collector.onStream?.(collector.streamText || '', '')
215
+ } else if (event.type === 'turn/end') {
216
+ collector.reason = event.data?.reason || event.data
217
+ }
218
+ }
219
+
220
+ async start() {
221
+ const onHost = (session, event) => this.handleHostEvent(session, event)
222
+ const unsubs = [
223
+ this.ctx.on('session/event', onHost),
224
+ this.ctx.on('turn/end', (session, data) => onHost(session, { type: 'turn/end', data })),
225
+ this.ctx.on('turn/start', (session, data) => onHost(session, { type: 'turn/start', data })),
226
+ this.ctx.on('assistant/message', (session, message) => onHost(session, { type: 'assistant/message', data: { message } })),
227
+ this.ctx.on('assistant/chunk', (session, chunk) => onHost(session, { type: 'assistant/chunk', data: { chunk } })),
228
+ ]
229
+ this.disposeListener = () => {
230
+ for (const un of unsubs) {
231
+ if (typeof un === 'function') un()
232
+ }
233
+ }
219
234
  const adapters = createAdapters({
220
235
  config: this.config,
221
236
  onMessage: (input) => this.handleMessage(input),
package/lib/index.js CHANGED
@@ -149,7 +149,57 @@ export function apply(ctx, config) {
149
149
  const turnStarts = new Map()
150
150
  const getWebServer = () => ctx.get?.('webServer') || ctx.webServer
151
151
 
152
+ let webhookRouteDispose = null
153
+ let currentWebhookPath = ''
154
+
155
+ const webhookPath = () => {
156
+ const p = String(source().telegram?.webhookPath || '').trim()
157
+ return p || '/dsh-messenger-gateway/telegram/webhook'
158
+ }
159
+
160
+ const syncWebhookRoute = () => {
161
+ const targetPath = webhookPath()
162
+ if (targetPath === currentWebhookPath && webhookRouteDispose) return
163
+ if (typeof webhookRouteDispose === 'function') {
164
+ try { webhookRouteDispose() } catch (e) { /* ignore */ }
165
+ webhookRouteDispose = null
166
+ }
167
+ currentWebhookPath = targetPath
168
+ const ws = getWebServer()
169
+ if (!ws?.register) return
170
+ webhookRouteDispose = ws.register({
171
+ kind: 'exact',
172
+ path: targetPath,
173
+ handler: async (req, res) => {
174
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
175
+ const cfg = source().telegram || {}
176
+ if (cfg.transport !== 'webhook') return writeJson(res, 404, { ok: false, error: 'webhook transport disabled' })
177
+ const secret = String(cfg.webhookSecret || '').trim()
178
+ if (!secret) {
179
+ return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
180
+ }
181
+ const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
182
+ if (!timingSafeCompare(hdr, secret)) {
183
+ return writeJson(res, 403, { ok: false, error: 'bad secret' })
184
+ }
185
+ const gw = getGw()
186
+ const adapter = gw?.getAdapter?.('telegram')
187
+ if (!adapter?.handleWebhookUpdate) return writeJson(res, 503, { ok: false, error: 'telegram adapter unavailable' })
188
+ const parsed = await readJsonSafe(req, res, 2_097_152)
189
+ if (!parsed.ok) return
190
+ const update = parsed.data
191
+ try {
192
+ await adapter.handleWebhookUpdate(update)
193
+ writeJson(res, 200, { ok: true })
194
+ } catch (err) {
195
+ writeJson(res, 500, { ok: false, error: err.message })
196
+ }
197
+ },
198
+ })
199
+ }
200
+
152
201
  const sync = () => {
202
+ syncWebhookRoute()
153
203
  if (gateway) { gateway.stop(); gateway = undefined }
154
204
  const effective = source()
155
205
  if (effective.enabled === false) return
@@ -292,51 +342,69 @@ export function apply(ctx, config) {
292
342
  const gw = getGw()
293
343
  if (gw) gw.syncTelegramCommands().catch((err) => ctx.logger?.debug?.(`sync telegram commands on skills change: ${err?.message || err}`))
294
344
  }
295
- const unbind = ctx.on('skills/change', onSkillsChange)
296
- return () => { if (typeof unbind === 'function') unbind() }
345
+ const unbind1 = ctx.on('skills/change', onSkillsChange)
346
+ const unbind2 = ctx.on('skills/updated', onSkillsChange)
347
+ const unbind3 = ctx.on('skills/update', onSkillsChange)
348
+ return () => {
349
+ if (typeof unbind1 === 'function') unbind1()
350
+ if (typeof unbind2 === 'function') unbind2()
351
+ if (typeof unbind3 === 'function') unbind3()
352
+ }
297
353
  }, 'dsh-messenger-gateway: dynamic skills sync')
298
354
 
299
355
  // Notify → Telegram home bridge (non-msgw sessions only)
300
- ctx.effect(() => ctx.on('session/event', (session, event) => {
301
- const cfg = source().telegram?.notifyBridge
302
- if (!cfg?.enabled) return
303
- if (event.type === 'turn/start') {
304
- const now = Date.now()
305
- if (turnStarts.size > 50) {
306
- for (const [sid, ts] of turnStarts.entries()) {
307
- if (now - ts > 7_200_000) turnStarts.delete(sid)
356
+ ctx.effect(() => {
357
+ const handleNotify = (session, event) => {
358
+ if (!session || !event) return
359
+ const cfg = source().telegram?.notifyBridge
360
+ if (!cfg?.enabled) return
361
+ if (event.type === 'turn/start') {
362
+ const now = Date.now()
363
+ if (turnStarts.size > 50) {
364
+ for (const [sid, ts] of turnStarts.entries()) {
365
+ if (now - ts > 7_200_000) turnStarts.delete(sid)
366
+ }
308
367
  }
368
+ turnStarts.set(String(session.id), now)
369
+ return
370
+ }
371
+ if (event.type !== 'turn/end') return
372
+ const prefixes = cfg.excludeSessionPrefixes || ['msgw-']
373
+ if (isExcludedSession(session.id, prefixes)) return
374
+ const reason = event.data?.reason
375
+ const kind = reason?.kind === 'completed' ? 'task_done' : 'error'
376
+ const events = new Set(cfg.events?.length ? cfg.events : ['task_done', 'error'])
377
+ if (!events.has(kind)) return
378
+ const gw = getGw()
379
+ if (!gw) return
380
+ const started = turnStarts.get(String(session.id))
381
+ turnStarts.delete(String(session.id))
382
+ const dur = started ? Math.round((Date.now() - started) / 1000) : null
383
+ const title = kind === 'task_done' ? 'Task completed' : 'Agent error'
384
+ const lines = [
385
+ `【${title}】`,
386
+ `session: ${session.id}`,
387
+ ]
388
+ if (dur != null) lines.push(`duration: ${dur}s`)
389
+ if (reason?.kind === 'error') {
390
+ lines.push(`error: ${reason.error?.message || reason.error?.code || 'unknown'}`)
309
391
  }
310
- turnStarts.set(String(session.id), now)
311
- return
392
+ const homeName = cfg.home || 'default'
393
+ const home = gw.messenger.home?.() || null
394
+ if (home && gw.isMuted?.(home.chatId)) return
395
+ gw.messengerSend({ platform: 'telegram', home: homeName }, { text: lines.join('\n') })
396
+ .catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
312
397
  }
313
- if (event.type !== 'turn/end') return
314
- const prefixes = cfg.excludeSessionPrefixes || ['msgw-']
315
- if (isExcludedSession(session.id, prefixes)) return
316
- const reason = event.data?.reason
317
- const kind = reason?.kind === 'completed' ? 'task_done' : 'error'
318
- const events = new Set(cfg.events?.length ? cfg.events : ['task_done', 'error'])
319
- if (!events.has(kind)) return
320
- const gw = getGw()
321
- if (!gw) return
322
- const started = turnStarts.get(String(session.id))
323
- turnStarts.delete(String(session.id))
324
- const dur = started ? Math.round((Date.now() - started) / 1000) : null
325
- const title = kind === 'task_done' ? 'Task completed' : 'Agent error'
326
- const lines = [
327
- `【${title}】`,
328
- `session: ${session.id}`,
329
- ]
330
- if (dur != null) lines.push(`duration: ${dur}s`)
331
- if (reason?.kind === 'error') {
332
- lines.push(`error: ${reason.error?.message || reason.error?.code || 'unknown'}`)
398
+
399
+ const un1 = ctx.on('session/event', handleNotify)
400
+ const un2 = ctx.on('turn/end', (session, data) => handleNotify(session, { type: 'turn/end', data }))
401
+ const un3 = ctx.on('turn/start', (session, data) => handleNotify(session, { type: 'turn/start', data }))
402
+ return () => {
403
+ if (typeof un1 === 'function') un1()
404
+ if (typeof un2 === 'function') un2()
405
+ if (typeof un3 === 'function') un3()
333
406
  }
334
- const homeName = cfg.home || 'default'
335
- const home = gw.messenger.home?.() || null
336
- if (home && gw.isMuted?.(home.chatId)) return
337
- gw.messengerSend({ platform: 'telegram', home: homeName }, { text: lines.join('\n') })
338
- .catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
339
- }), 'dsh-messenger-gateway: notify bridge')
407
+ }, 'dsh-messenger-gateway: notify bridge')
340
408
 
341
409
  ctx.effect(() => getWebServer().register({
342
410
  kind: 'exact', path: '/dsh-messenger-gateway/status',
@@ -538,39 +606,12 @@ export function apply(ctx, config) {
538
606
  },
539
607
  }), 'dsh-messenger-gateway: pairing reject')
540
608
 
541
- const webhookPath = () => {
542
- const p = String(source().telegram?.webhookPath || '').trim()
543
- return p || '/dsh-messenger-gateway/telegram/webhook'
544
- }
545
-
546
- ctx.effect(() => getWebServer().register({
547
- kind: 'exact', path: webhookPath(),
548
- handler: async (req, res) => {
549
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
550
- const cfg = source().telegram || {}
551
- if (cfg.transport !== 'webhook') return writeJson(res, 404, { ok: false, error: 'webhook transport disabled' })
552
- const secret = String(cfg.webhookSecret || '').trim()
553
- if (!secret) {
554
- return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
555
- }
556
- const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
557
- if (!timingSafeCompare(hdr, secret)) {
558
- return writeJson(res, 403, { ok: false, error: 'bad secret' })
559
- }
560
- const gw = getGw()
561
- const adapter = gw?.getAdapter?.('telegram')
562
- if (!adapter?.handleWebhookUpdate) return writeJson(res, 503, { ok: false, error: 'telegram adapter unavailable' })
563
- const parsed = await readJsonSafe(req, res, 2_097_152)
564
- if (!parsed.ok) return
565
- const update = parsed.data
566
- try {
567
- await adapter.handleWebhookUpdate(update)
568
- writeJson(res, 200, { ok: true })
569
- } catch (err) {
570
- writeJson(res, 500, { ok: false, error: err.message })
571
- }
572
- },
573
- }), 'dsh-messenger-gateway: telegram webhook')
609
+ ctx.effect(() => () => {
610
+ if (typeof webhookRouteDispose === 'function') {
611
+ try { webhookRouteDispose() } catch (e) { /* ignore */ }
612
+ webhookRouteDispose = null
613
+ }
614
+ }, 'dsh-messenger-gateway: telegram webhook')
574
615
 
575
616
  registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/send', 'send', source)
576
617
  registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress', source)
@@ -583,6 +624,9 @@ export function apply(ctx, config) {
583
624
  endpoint: '/dsh-messenger-gateway/update',
584
625
  }), 'dsh-messenger-gateway: plugin updater')
585
626
 
586
- ctx.on('dispose', () => { if (gateway) gateway.stop() })
627
+ ctx.on('dispose', () => {
628
+ if (gateway) gateway.stop()
629
+ if (typeof webhookRouteDispose === 'function') webhookRouteDispose()
630
+ })
587
631
  sync()
588
632
  }
@@ -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/stream.js CHANGED
@@ -1,8 +1,12 @@
1
1
  /** Helpers for Telegram streaming edits + progress. */
2
2
 
3
3
  export function extractTextDelta(chunk) {
4
+ if (typeof chunk === 'string') return chunk
4
5
  if (!chunk || typeof chunk !== 'object') return ''
5
6
  if (chunk.type === 'text-delta' && typeof chunk.text === 'string') return chunk.text
7
+ if (chunk.type === 'text' && typeof chunk.text === 'string') return chunk.text
8
+ if (!chunk.type && typeof chunk.text === 'string') return chunk.text
9
+ if (!chunk.type && typeof chunk.delta === 'string') return chunk.delta
6
10
  return ''
7
11
  }
8
12
 
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.12",
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",