@goodandready/dsh-messenger-gateway 0.4.9 → 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
+
@@ -1,5 +1,6 @@
1
+ import { realpathSync } from 'node:fs'
1
2
  import { readdir, stat, readFile } from 'node:fs/promises'
2
- import { resolve, normalize, relative, join, extname, isAbsolute } from 'node:path'
3
+ import { resolve, normalize, relative, join, extname, isAbsolute, sep, dirname } from 'node:path'
3
4
 
4
5
  export function formatFileSize(bytes) {
5
6
  if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
@@ -9,6 +10,17 @@ export function formatFileSize(bytes) {
9
10
  return `${val} ${units[i]}`
10
11
  }
11
12
 
13
+ function isSubpath(parent, child) {
14
+ const normParent = normalize(parent)
15
+ const normChild = normalize(child)
16
+ if (process.platform === 'win32') {
17
+ const p = normParent.toLowerCase()
18
+ const c = normChild.toLowerCase()
19
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep)
20
+ }
21
+ return normChild === normParent || normChild.startsWith(normParent.endsWith(sep) ? normParent : normParent + sep)
22
+ }
23
+
12
24
  export function resolveSafePath(baseCwd, userPath = '.') {
13
25
  const base = resolve(normalize(baseCwd || process.cwd()))
14
26
  const cleanUser = String(userPath || '').trim() || '.'
@@ -21,6 +33,46 @@ export function resolveSafePath(baseCwd, userPath = '.') {
21
33
  err.code = 'ERR_PATH_TRAVERSAL'
22
34
  throw err
23
35
  }
36
+
37
+ try {
38
+ const realBase = realpathSync(base)
39
+ try {
40
+ const realTarget = realpathSync(target)
41
+ if (!isSubpath(realBase, realTarget)) {
42
+ const err = new Error('Access denied: symlink resolves out of workspace')
43
+ err.code = 'ERR_PATH_TRAVERSAL'
44
+ throw err
45
+ }
46
+ } catch (targetErr) {
47
+ if (targetErr?.code === 'ERR_PATH_TRAVERSAL') throw targetErr
48
+ if (targetErr?.code === 'ENOENT') {
49
+ let curr = dirname(target)
50
+ while (curr === base || isSubpath(base, curr)) {
51
+ try {
52
+ const realCurr = realpathSync(curr)
53
+ if (!isSubpath(realBase, realCurr)) {
54
+ const err = new Error('Access denied: symlink ancestor resolves out of workspace')
55
+ err.code = 'ERR_PATH_TRAVERSAL'
56
+ throw err
57
+ }
58
+ break
59
+ } catch (ancestorErr) {
60
+ if (ancestorErr?.code === 'ERR_PATH_TRAVERSAL') throw ancestorErr
61
+ if (ancestorErr?.code === 'ENOENT') {
62
+ const parent = dirname(curr)
63
+ if (parent === curr) break
64
+ curr = parent
65
+ continue
66
+ }
67
+ break
68
+ }
69
+ }
70
+ }
71
+ }
72
+ } catch (baseErr) {
73
+ if (baseErr?.code === 'ERR_PATH_TRAVERSAL') throw baseErr
74
+ }
75
+
24
76
  return { base, target, relativePath: rel || '.' }
25
77
  }
26
78
 
package/lib/http.js CHANGED
@@ -36,7 +36,7 @@ export function isLoopback(value) {
36
36
  || address.startsWith('::ffff:127.')
37
37
  }
38
38
 
39
- export function isTrustedSettingsRequest(req) {
39
+ export function isTrustedSettingsRequest(req, expectedSecret) {
40
40
  const remote = req?.socket?.remoteAddress
41
41
  const host = String(req?.headers?.host || '')
42
42
  if (!host) return false
@@ -44,6 +44,21 @@ export function isTrustedSettingsRequest(req) {
44
44
  const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
45
45
  if (site === 'cross-site') return false
46
46
 
47
+ const secret = String(expectedSecret || '').trim()
48
+ if (secret) {
49
+ const authHeader = req?.headers?.authorization || ''
50
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
51
+ const tokenHeader = req?.headers?.['x-webhook-secret'] || ''
52
+ const provided = bearer || tokenHeader
53
+ if (provided && timingSafeCompare(provided, secret)) {
54
+ return true
55
+ }
56
+ }
57
+
58
+ if (remote !== undefined && !isLoopback(remote)) {
59
+ return false
60
+ }
61
+
47
62
  const origin = String(req?.headers?.origin || '')
48
63
  if (origin) {
49
64
  try {
@@ -67,7 +82,6 @@ export function isTrustedSettingsRequest(req) {
67
82
  }
68
83
 
69
84
  if (site === 'same-origin') {
70
- if (remote !== undefined && !isLoopback(remote)) return false
71
85
  return true
72
86
  }
73
87
 
@@ -78,10 +92,9 @@ export function isAuthorizedMessengerRequest(req, webhooksConfig) {
78
92
  const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
79
93
  if (site === 'cross-site') return false
80
94
 
81
- if (isTrustedSettingsRequest(req)) return true
82
- if (isLoopback(req?.socket?.remoteAddress)) return true
83
-
84
95
  const secret = String(webhooksConfig?.secret || '').trim()
96
+ if (isTrustedSettingsRequest(req, secret)) return true
97
+ if (isLoopback(req?.socket?.remoteAddress)) return true
85
98
  if (secret) {
86
99
  const authHeader = req?.headers?.authorization || ''
87
100
  const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
package/lib/index.js CHANGED
@@ -380,7 +380,7 @@ export function apply(ctx, config) {
380
380
  kind: 'exact', path: '/dsh-messenger-gateway/smoke',
381
381
  handler: async (req, res) => {
382
382
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
383
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
383
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
384
384
  const gw = getGw()
385
385
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
386
386
  const parsed = await readJsonSafe(req, res, 65536)
@@ -457,7 +457,7 @@ export function apply(ctx, config) {
457
457
  handler: async (req, res) => {
458
458
  if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
459
459
  if (req.method !== 'PUT') return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
460
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
460
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
461
461
  if (!settingsApi?.isReady?.()) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
462
462
  const parsedConfig = await readJsonSafe(req, res, 1_048_576)
463
463
  if (!parsedConfig.ok) return
@@ -499,7 +499,7 @@ export function apply(ctx, config) {
499
499
  kind: 'exact', path: '/dsh-messenger-gateway/pairing',
500
500
  handler: async (req, res) => {
501
501
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
502
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
502
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
503
503
  const gw = getGw()
504
504
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
505
505
  writeJson(res, 200, {
@@ -514,7 +514,7 @@ export function apply(ctx, config) {
514
514
  kind: 'exact', path: '/dsh-messenger-gateway/pairing/approve',
515
515
  handler: async (req, res) => {
516
516
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
517
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
517
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
518
518
  const gw = getGw()
519
519
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
520
520
  const parsed = await readJsonSafe(req, res, 65536)
@@ -528,7 +528,7 @@ export function apply(ctx, config) {
528
528
  kind: 'exact', path: '/dsh-messenger-gateway/pairing/reject',
529
529
  handler: async (req, res) => {
530
530
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
531
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
531
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
532
532
  const gw = getGw()
533
533
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
534
534
  const parsed = await readJsonSafe(req, res, 65536)
@@ -9,11 +9,20 @@ export function getLocaleDictionary(locale = 'en') {
9
9
  return en
10
10
  }
11
11
 
12
- export function t(key, params = {}, locale = 'en') {
12
+ export function escapeHtml(text) {
13
+ return String(text ?? '')
14
+ .replace(/&/g, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ }
18
+
19
+ export function t(key, params = {}, locale = 'en', options = {}) {
13
20
  const dict = getLocaleDictionary(locale)
14
21
  let template = dict[key] || en[key] || key
22
+ const escape = options.escape !== false
15
23
  for (const [k, v] of Object.entries(params)) {
16
- template = template.replaceAll(`{${k}}`, String(v))
24
+ const val = escape ? escapeHtml(v) : String(v)
25
+ template = template.replaceAll(`{${k}}`, val)
17
26
  }
18
27
  return template
19
28
  }
@@ -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
  }
File without changes
@@ -7,12 +7,33 @@ export function escapeHtml(text) {
7
7
 
8
8
  const PH = '\uE000'
9
9
 
10
+ const SAFE_SCHEMES = new Set(['https:', 'http:', 'tg:'])
11
+
12
+ export function isSafeUrl(rawUrl) {
13
+ if (!rawUrl || typeof rawUrl !== 'string') return false
14
+ const clean = rawUrl.replace(/&amp;/g, '&').trim()
15
+ try {
16
+ const parsed = new URL(clean)
17
+ return SAFE_SCHEMES.has(parsed.protocol.toLowerCase())
18
+ } catch {
19
+ if (/^tg:\/\//i.test(clean)) return true
20
+ return false
21
+ }
22
+ }
23
+
24
+ export function formatSafeLink(label, url) {
25
+ if (!isSafeUrl(url)) {
26
+ return `${label} (${url})`
27
+ }
28
+ return `<a href="${url.replace(/"/g, '%22')}">${label}</a>`
29
+ }
30
+
10
31
  // Render inline markdown (bold/italic/code/links) inside already-HTML-escaped text.
11
32
  function renderInline(t) {
12
33
  let s = escapeHtml(t)
13
34
  s = s.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
14
35
  s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>')
15
- s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${url.replace(/"/g, '%22')}">${label}</a>`)
36
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => formatSafeLink(label, url))
16
37
  s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<i>$2</i>')
17
38
  return s
18
39
  }
@@ -123,7 +144,7 @@ export function markdownToTelegramHtml(markdown) {
123
144
  text = convertDetails(text, stash)
124
145
  text = convertTaskLists(text, stash)
125
146
  text = escapeHtml(text)
126
- text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${url.replace(/"/g, '%22')}">${label}</a>`)
147
+ text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => formatSafeLink(label, url))
127
148
  text = text.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
128
149
  text = text.replace(/__([^_\n]+)__/g, '<b>$1</b>')
129
150
  text = text.replace(/~~([^~\n]+)~~/g, '<s>$1</s>')
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.9",
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",