@goodandready/dsh-messenger-gateway 0.3.12 → 0.3.14

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/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // dsh-messenger-gateway — browser (client) half.
2
2
  // Settings card on Plugins → Plugin settings tab (settings.plugin.item).
3
- // Fallback to sidebar settings.section when the slot is unavailable.
3
+ // Config managed via reactive ctx.settingsScope snapshot.
4
4
 
5
5
  window.__ModuleLoader__.load({
6
6
  id: '@goodandready/dsh-messenger-gateway',
@@ -52,6 +52,7 @@ const css =
52
52
  description: 'Telegram bot: text, voice, photos and documents.',
53
53
  loading: 'Loading…',
54
54
  save: 'Save',
55
+ saved: 'Saved',
55
56
  saving: 'Saving…',
56
57
  showAdvanced: 'Advanced settings',
57
58
  hideAdvanced: 'Hide advanced',
@@ -125,6 +126,7 @@ const css =
125
126
  description: 'Telegram-бот: текст, голос, фото и документы.',
126
127
  loading: 'Загрузка…',
127
128
  save: 'Сохранить',
129
+ saved: 'Сохранено',
128
130
  saving: 'Сохранение…',
129
131
  showAdvanced: 'Расширенные настройки',
130
132
  hideAdvanced: 'Скрыть расширенные',
@@ -227,16 +229,104 @@ const css =
227
229
  )
228
230
  }
229
231
 
230
- function MessengerSettingsForm({ t }) {
231
- const [cfg, setCfg] = React.useState(null)
232
+ const SETTINGS_KEYS = ['enabled', 'telegram', 'agent', 'media', 'tts']
233
+
234
+ function draftFromStored(s) {
235
+ const src = (s && typeof s === 'object') ? s : {}
236
+ const t = src.telegram || {}
237
+ const a = src.agent || {}
238
+ const m = src.media || {}
239
+ const tts = src.tts || {}
240
+ return {
241
+ enabled: src.enabled !== false,
242
+ telegram: {
243
+ enabled: t.enabled === true,
244
+ allowedUserIds: Array.isArray(t.allowedUserIds) ? t.allowedUserIds : [],
245
+ pollTimeoutSeconds: Number(t.pollTimeoutSeconds) || 50,
246
+ pollIntervalMs: Number(t.pollIntervalMs) || 500,
247
+ commands: Array.isArray(t.commands) ? t.commands : [],
248
+ textFormat: t.textFormat === 'plain' ? 'plain' : 'html',
249
+ homeChatId: t.homeChatId != null ? t.homeChatId : '',
250
+ homeThreadId: Number(t.homeThreadId) || 0,
251
+ homes: Array.isArray(t.homes) ? t.homes : [],
252
+ pairingEnabled: t.pairingEnabled !== false,
253
+ streaming: t.streaming === true,
254
+ streamEditIntervalMs: Number(t.streamEditIntervalMs) || 1200,
255
+ progressEnabled: t.progressEnabled !== false,
256
+ approvalsEnabled: t.approvalsEnabled !== false,
257
+ groupsEnabled: t.groupsEnabled !== false,
258
+ groupRequireMention: t.groupRequireMention !== false,
259
+ reactionsEnabled: t.reactionsEnabled !== false,
260
+ statusIndicator: t.statusIndicator === true,
261
+ statusOnline: t.statusOnline || 'Online',
262
+ statusOffline: t.statusOffline || 'Offline',
263
+ transport: t.transport === 'webhook' ? 'webhook' : 'poll',
264
+ webhookUrl: t.webhookUrl || '',
265
+ webhookPath: t.webhookPath || '/dsh-messenger-gateway/telegram/webhook',
266
+ voiceMode: t.voiceMode || 'mirror',
267
+ quickActions: t.quickActions === true,
268
+ artifactPreviews: t.artifactPreviews !== false,
269
+ notifyBridge: t.notifyBridge || { enabled: false, events: ['task_done', 'error'], home: 'default', excludeSessionPrefixes: ['msgw-'] },
270
+ alerts: t.alerts || { enabled: false, chatId: '', threadId: 0, home: '', events: ['error', 'pairing'] },
271
+ botToken: t.botToken || '',
272
+ webhookSecret: t.webhookSecret || '',
273
+ },
274
+ agent: {
275
+ provider: a.provider || '',
276
+ model: a.model || '',
277
+ instructionPrefix: a.instructionPrefix || '',
278
+ maxMessageLength: Number(a.maxMessageLength) || 4000,
279
+ turnTimeoutMs: Number(a.turnTimeoutMs) || 600000,
280
+ idleTimeoutMs: Number(a.idleTimeoutMs) || 3600000,
281
+ photoOnlyMode: a.photoOnlyMode || 'prompt',
282
+ sessionScope: a.sessionScope || 'user',
283
+ },
284
+ media: {
285
+ maxDocBytes: Number(m.maxDocBytes) || 20971520,
286
+ maxImageBytes: Number(m.maxImageBytes) || 20971520,
287
+ maxTextInjectBytes: Number(m.maxTextInjectBytes) || 102400,
288
+ },
289
+ tts: {
290
+ enabled: tts.enabled === true,
291
+ maxChars: Number(tts.maxChars) || 4000,
292
+ voiceSummary: tts.voiceSummary === true,
293
+ },
294
+ }
295
+ }
296
+
297
+ function MessengerSettingsForm({ t, ctx }) {
232
298
  const [token, setToken] = React.useState('')
233
299
  const [webhookSecret, setWebhookSecret] = React.useState('')
234
300
  const [allowText, setAllowText] = React.useState('')
235
301
  const [pending, setPending] = React.useState([])
236
302
  const [err, setErr] = React.useState('')
303
+ const [msg, setMsg] = React.useState('')
237
304
  const [busy, setBusy] = React.useState(false)
238
305
  const [showAdvanced, setShowAdvanced] = React.useState(false)
239
306
 
307
+ const scope = React.useMemo(
308
+ () => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined),
309
+ [ctx],
310
+ )
311
+
312
+ const snapshot = React.useSyncExternalStore(
313
+ React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
314
+ React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
315
+ React.useCallback(() => ({ status: 'loading' }), []),
316
+ )
317
+
318
+ const snapStatus = (snapshot && snapshot.status) || 'loading'
319
+ const stored = (snapshot && snapshot.value) || {}
320
+
321
+ const [cfg, setCfg] = React.useState(null)
322
+
323
+ React.useEffect(() => {
324
+ if (snapStatus === 'ready' && cfg === null) {
325
+ setCfg(draftFromStored(stored))
326
+ setAllowText(idsToText(stored?.telegram?.allowedUserIds))
327
+ }
328
+ }, [snapStatus, stored, cfg])
329
+
240
330
  const loadPairing = React.useCallback(async () => {
241
331
  try {
242
332
  const res = await fetch('/dsh-messenger-gateway/pairing', { credentials: 'same-origin' })
@@ -245,16 +335,9 @@ const css =
245
335
  } catch {}
246
336
  }, [])
247
337
 
248
- const load = React.useCallback(async () => {
249
- const res = await fetch('/dsh-messenger-gateway/config', { credentials: 'same-origin' })
250
- const data = await res.json()
251
- if (!res.ok || !data.ok) throw new Error(data.error || res.status)
252
- setCfg(data.config)
253
- setAllowText(idsToText(data.config?.telegram?.allowedUserIds))
254
- await loadPairing()
255
- }, [loadPairing])
256
-
257
- React.useEffect(() => { load().catch((e) => setErr(String(e.message || e))) }, [load])
338
+ React.useEffect(() => {
339
+ if (snapStatus === 'ready') loadPairing()
340
+ }, [snapStatus, loadPairing])
258
341
 
259
342
  const mergePatch = (base, patch) => ({
260
343
  ...base,
@@ -266,26 +349,47 @@ const css =
266
349
  })
267
350
 
268
351
  const save = async (patch = {}) => {
269
- setBusy(true); setErr('')
352
+ if (!scope || !cfg) return
353
+ setBusy(true); setErr(''); setMsg('')
270
354
  try {
271
355
  let next = mergePatch(cfg, patch)
272
356
  if (token.trim()) next.telegram = { ...next.telegram, botToken: token.trim() }
273
357
  if (webhookSecret.trim()) next.telegram = { ...next.telegram, webhookSecret: webhookSecret.trim() }
274
358
  next.telegram.allowedUserIds = textToIds(allowText)
275
- const res = await fetch('/dsh-messenger-gateway/config', {
276
- method: 'PUT', credentials: 'same-origin',
277
- headers: { 'Content-Type': 'application/json' },
278
- body: JSON.stringify({ config: next }),
279
- })
280
- const data = await res.json()
281
- if (!res.ok || !data.ok) throw new Error(data.error || res.status)
282
- setCfg(data.config); setToken(''); setWebhookSecret('')
283
- setAllowText(idsToText(data.config?.telegram?.allowedUserIds))
359
+
360
+ const broken = []
361
+ for (const key of SETTINGS_KEYS) {
362
+ if (next[key] !== undefined) {
363
+ try {
364
+ await scope.set(key, next[key])
365
+ } catch (e) {
366
+ broken.push(key + ': ' + (e && e.message || String(e)))
367
+ }
368
+ }
369
+ }
370
+ if (broken.length) {
371
+ setErr('Save failed — ' + broken.join('; '))
372
+ return
373
+ }
374
+ setCfg(next)
375
+ setToken('')
376
+ setWebhookSecret('')
377
+ setMsg(t('saved'))
378
+ setTimeout(() => setMsg(''), 3000)
284
379
  await loadPairing()
285
380
  } catch (e) { setErr(String(e.message || e)) } finally { setBusy(false) }
286
381
  }
287
382
 
288
- if (!cfg) return React.createElement('div', null, err || t('loading'))
383
+ if (snapStatus === 'loading') {
384
+ return React.createElement('div', { className: 'msgw-sub', style: { padding: '12px 0' } }, t('loading'))
385
+ }
386
+ if (snapStatus !== 'ready') {
387
+ return React.createElement('div', { className: 'msgw-err', style: { padding: '12px 0' } },
388
+ 'Settings unavailable (snapshot status: ' + snapStatus + '). Host namespace may be missing.')
389
+ }
390
+ if (!cfg) {
391
+ return React.createElement('div', { className: 'msgw-sub', style: { padding: '12px 0' } }, t('loading'))
392
+ }
289
393
 
290
394
  return React.createElement('div', { className: 'msgw-form' },
291
395
  React.createElement('div', null,
@@ -475,6 +579,7 @@ const css =
475
579
  ) : null,
476
580
 
477
581
  err ? React.createElement('div', { className: 'msgw-err' }, err) : null,
582
+ msg ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-success-primary, #10b981)', fontSize: '13px', padding: '8px 0' } }, msg) : null,
478
583
  React.createElement('div', { className: 'msgw-foot' },
479
584
  React.createElement('button', { type: 'button', className: 'msgw-save', disabled: busy, onClick: () => save({}) }, busy ? t('saving') : t('save')),
480
585
  ),
@@ -498,7 +603,7 @@ const css =
498
603
  React.createElement('span', { className: 'msgw-chev' }, open ? '\u25B2' : '\u25BC'),
499
604
  ),
500
605
  open ? React.createElement('div', { className: 'msgw-body' },
501
- React.createElement(MessengerSettingsForm, { t }),
606
+ React.createElement(MessengerSettingsForm, { t, ctx: props.ctx }),
502
607
  ) : null,
503
608
  )
504
609
  }
@@ -507,36 +612,18 @@ const css =
507
612
  ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-messenger-gateway: dictionaries')
508
613
  function useLocale() { return useActiveLocale(ctx) }
509
614
 
510
- const tryPluginItem = () => {
511
- try {
512
- ctx.slots.inject('settings.plugin.item', () =>
513
- ctx.slots.register({
514
- name: 'settings.plugin.item',
515
- key: NS,
516
- locale: NS,
517
- inject: () => ({ ctx }),
518
- }, (props) => React.createElement(PluginCard, { ...props, locale: useLocale() })),
519
- )
520
- return true
521
- } catch {
522
- return false
523
- }
524
- }
525
-
526
- if (!tryPluginItem()) {
527
- ctx.slots.inject('settings.section', () => ctx.slots.register({
528
- name: 'settings.section',
529
- id: NS,
530
- order: 36,
531
- label: () => makeT(useActiveLocale(ctx))('title'),
532
- }, (props) => React.createElement('div', { style: { padding: 16 } },
533
- React.createElement(MessengerSettingsForm, { t: makeT(useActiveLocale(ctx)) }),
534
- )))
535
- }
615
+ ctx.slots.inject('settings.plugin.item', () =>
616
+ ctx.slots.register({
617
+ name: 'settings.plugin.item',
618
+ key: NS,
619
+ locale: NS,
620
+ inject: () => ({ ctx }),
621
+ }, (props) => React.createElement(PluginCard, { ...props, ctx, locale: useLocale() })),
622
+ )
536
623
  }
537
624
 
538
625
  exports.apply = apply
539
- exports.inject = ['slots', 'locale']
626
+ exports.inject = ['slots', 'locale', 'settingsScope']
540
627
  return module.exports
541
628
  },
542
629
  })
package/lib/gateway.js CHANGED
@@ -174,11 +174,10 @@ export class Gateway {
174
174
  this.ctx.logger?.warn?.(`dsh-messenger-gateway: ${adapter.name}: ${err.message}`)
175
175
  }
176
176
  }
177
- const idleMs = Number(this.config.agent?.idleTimeoutMs) || 0
178
- if (idleMs > 0) {
179
- this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
180
- this.idleTimer.unref?.()
181
- }
177
+ const rawIdle = Number(this.config.agent?.idleTimeoutMs)
178
+ const idleMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle : 86_400_000
179
+ this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
180
+ this.idleTimer.unref?.()
182
181
  this.scheduler.start()
183
182
  }
184
183
 
@@ -565,30 +564,37 @@ export class Gateway {
565
564
  if (cmd === '/start') return reply('Шлюз подключён. Пишите сообщение агенту. /help — команды.', { replyMarkup: REMOVE_REPLY_KEYBOARD })
566
565
  if (cmd === '/help') {
567
566
  return reply([
568
- 'Команды:',
569
- '/help — справка',
570
- '/new — новая сессия',
571
- '/whoamiваш id',
572
- '/stopпрервать текущий ответ',
573
- '/statusстатус шлюза',
574
- '/model — текущая модель; /model provider model — сменить',
575
- '/role [name] — персоны и роли агента (/role list)',
576
- '/skills / /toolsсписок инструментов и навыков',
577
- '/fork — ответвление текущей сессии',
578
- '/export — выгрузка истории диалога в Markdown',
579
- '/rewind [N] — откат последних N сообщений',
580
- '/files [dir] менеджер файлов в рабочей папке агента',
581
- '/get <path>скачать файл из рабочей папки в Telegram',
582
- '/pair CODEодобрить пользователя',
583
- '/sethome [name]этот чат = home (имя опционально)',
584
- '/homeсписок home',
585
- '/setalert — назначить этот чат каналом алертов',
586
- '/alert [test] — статус канала алертов и тест',
587
- '/remind <время> <текст> напоминание (/remind 10m текст, /remind list)',
588
- '/voice on|off|status голосовые ответы',
589
- '/tts on|off|statusозвучка в этом чате',
590
- '/mute / /unmuteзаглушить уведомления в этот чат',
591
- '/keyboard on|off — быстрая клавиатура',
567
+ '📖 <b>Команды бота:</b>',
568
+ '',
569
+ '💬 <b>Диалог:</b>',
570
+ '/helpэта справка',
571
+ '/newновая сессия',
572
+ '/stopпрервать текущий ответ',
573
+ '/model — интерактивный выбор модели (/model list)',
574
+ '/role [name] — персоны и роли агента (/role list)',
575
+ ' /rewind [N]откат последних N ходов',
576
+ '/fork — форк сессии в новую ветку',
577
+ '/export — выгрузка истории в Markdown',
578
+ '',
579
+ '🛠️ <b>Инструменты и файлы:</b>',
580
+ '/skills / /tools список активных инструментов',
581
+ '/files [dir]проводник рабочей папки',
582
+ '/get <path>скачать файл в Telegram',
583
+ '/remind <время> <текст> напоминание (/remind 10m текст)',
584
+ '',
585
+ '⚙️ <b>Настройки:</b>',
586
+ '/statusстатус шлюза и модели',
587
+ '/topсистемные ресурсы (RAM, uptime)',
588
+ '/keyboard on|off — быстрые кнопки',
589
+ ' /voice on|off|statusголосовые ответы',
590
+ '/tts on|off|statusозвучка в этом чате',
591
+ '• /mute / /unmute — заглушить уведомления в этот чат',
592
+ '',
593
+ '🔒 <b>Доступ и каналы:</b>',
594
+ '• /whoami — ваш Telegram id',
595
+ '• /pair CODE — одобрить код сопряжения',
596
+ '• /sethome [name] — привязать домашний чат',
597
+ '• /setalert — назначить канал алертов',
592
598
  ].join('\n'))
593
599
  }
594
600
  if (cmd === '/role' || cmd === '/persona') {
@@ -1318,8 +1324,8 @@ export class Gateway {
1318
1324
  }
1319
1325
 
1320
1326
  reapIdle() {
1321
- const timeout = Number(this.config.agent?.idleTimeoutMs) || 0
1322
- if (timeout <= 0) return
1327
+ const rawTimeout = Number(this.config.agent?.idleTimeoutMs)
1328
+ const timeout = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 86_400_000
1323
1329
  const now = Date.now()
1324
1330
  for (const [key, chat] of this.chats) {
1325
1331
  if (!chat.turnActive && now - chat.lastUsed > timeout) {
package/lib/pairing.js CHANGED
@@ -1,5 +1,5 @@
1
- import { randomBytes } from 'node:crypto'
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
1
+ import { randomBytes, randomUUID } from 'node:crypto'
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'node:fs'
3
3
  import { dirname } from 'node:path'
4
4
 
5
5
  const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
@@ -11,6 +11,19 @@ export function generatePairingCode(length = 8) {
11
11
  return out
12
12
  }
13
13
 
14
+ function writeJsonAtomicSync(filePath, data) {
15
+ const dir = dirname(filePath)
16
+ mkdirSync(dir, { recursive: true })
17
+ const tmpPath = `${filePath}.${randomUUID().slice(0, 8)}.tmp`
18
+ try {
19
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
20
+ renameSync(tmpPath, filePath)
21
+ } catch (err) {
22
+ try { unlinkSync(tmpPath) } catch {}
23
+ throw err
24
+ }
25
+ }
26
+
14
27
  export function createPairingStore(filePath) {
15
28
  const state = { pending: {}, /** @type {number[]} */ approved: [] }
16
29
  if (filePath && existsSync(filePath)) {
@@ -22,8 +35,9 @@ export function createPairingStore(filePath) {
22
35
  }
23
36
  const persist = () => {
24
37
  if (!filePath) return
25
- mkdirSync(dirname(filePath), { recursive: true })
26
- writeFileSync(filePath, JSON.stringify({ approved: state.approved, pending: state.pending }, null, 2))
38
+ try {
39
+ writeJsonAtomicSync(filePath, { approved: state.approved, pending: state.pending })
40
+ } catch {}
27
41
  }
28
42
  const prune = () => {
29
43
  const now = Date.now()
@@ -107,4 +121,4 @@ export function createPairingStore(filePath) {
107
121
  return { ok: true, userId: row.userId, username: row.username }
108
122
  },
109
123
  }
110
- }
124
+ }
package/lib/scheduler.js CHANGED
@@ -1,6 +1,6 @@
1
- import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
- import { dirname } from 'node:path'
1
+ import { readFile } from 'node:fs/promises'
3
2
  import { randomUUID } from 'node:crypto'
3
+ import { writeJsonAtomic } from './storage-atomic.js'
4
4
 
5
5
  export function parseRelativeTime(input) {
6
6
  if (!input) return null
@@ -49,6 +49,7 @@ export function createScheduler(filePath, onDue) {
49
49
  const RETENTION_MS = 7 * 86400 * 1000
50
50
 
51
51
  async function save() {
52
+ if (!filePath) return
52
53
  try {
53
54
  const now = Date.now()
54
55
  tasks = tasks.filter((t) => {
@@ -56,8 +57,7 @@ export function createScheduler(filePath, onDue) {
56
57
  const finishTime = t.firedAt || t.createdAt || 0
57
58
  return (now - finishTime) < RETENTION_MS
58
59
  })
59
- await mkdir(dirname(filePath), { recursive: true })
60
- await writeFile(filePath, JSON.stringify(tasks, null, 2), 'utf8')
60
+ await writeJsonAtomic(filePath, tasks)
61
61
  } catch {}
62
62
  }
63
63
 
@@ -140,4 +140,4 @@ export function createScheduler(filePath, onDue) {
140
140
  checkDue,
141
141
  getTasks: () => tasks,
142
142
  }
143
- }
143
+ }
@@ -0,0 +1,21 @@
1
+ import { writeFile, rename, mkdir, unlink } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ /**
6
+ * Write JSON atomically via temporary file and rename.
7
+ * Guarantees that readers never observe partially written files.
8
+ */
9
+ export async function writeJsonAtomic(filePath, data) {
10
+ const dir = dirname(filePath)
11
+ await mkdir(dir, { recursive: true })
12
+ const tmpPath = `${filePath}.${randomUUID().slice(0, 8)}.tmp`
13
+ const serialized = JSON.stringify(data, null, 2)
14
+ try {
15
+ await writeFile(tmpPath, serialized, 'utf8')
16
+ await rename(tmpPath, filePath)
17
+ } catch (err) {
18
+ await unlink(tmpPath).catch(() => {})
19
+ throw err
20
+ }
21
+ }
package/lib/stream.js CHANGED
@@ -24,22 +24,48 @@ export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
24
24
  return combined.slice(0, maxLen - 1) + '…'
25
25
  }
26
26
 
27
+ export function parseRetryAfter(err) {
28
+ if (!err) return 0
29
+ const msg = err.message || String(err)
30
+ const match = /retry after (\d+)/i.exec(msg)
31
+ if (match) return parseInt(match[1], 10) * 1000
32
+ return 0
33
+ }
34
+
27
35
  export function createEditScheduler(editFn, intervalMs = 1200) {
28
36
  let pending = null
29
37
  let timer = null
30
38
  let lastSent = ''
31
39
  let inFlight = Promise.resolve()
40
+ let retryDelayMs = 0
41
+
32
42
  const flush = () => {
33
43
  timer = null
34
44
  if (pending === null || pending === lastSent) return
35
45
  const text = pending
36
46
  inFlight = inFlight.then(async () => {
37
47
  try {
48
+ if (retryDelayMs > 0) {
49
+ const delay = retryDelayMs
50
+ retryDelayMs = 0
51
+ await new Promise((r) => setTimeout(r, delay))
52
+ }
38
53
  await editFn(text)
39
54
  lastSent = text
40
- } catch {}
55
+ } catch (err) {
56
+ const retry = parseRetryAfter(err)
57
+ if (retry > 0) {
58
+ retryDelayMs = retry
59
+ // Re-schedule flush with retry delay
60
+ if (!timer) {
61
+ timer = setTimeout(flush, retry + 100)
62
+ timer.unref?.()
63
+ }
64
+ }
65
+ }
41
66
  })
42
67
  }
68
+
43
69
  return {
44
70
  push(text) {
45
71
  pending = text
@@ -51,6 +77,17 @@ export function createEditScheduler(editFn, intervalMs = 1200) {
51
77
  if (timer) { clearTimeout(timer); timer = null }
52
78
  flush()
53
79
  await inFlight
80
+ // If there's still a pending difference (e.g. rate limit delay happened), wait and retry
81
+ if (pending !== null && pending !== lastSent) {
82
+ if (retryDelayMs > 0) {
83
+ await new Promise((r) => setTimeout(r, retryDelayMs + 50))
84
+ retryDelayMs = 0
85
+ }
86
+ try {
87
+ await editFn(pending)
88
+ lastSent = pending
89
+ } catch {}
90
+ }
54
91
  },
55
92
  }
56
93
  }
@@ -61,4 +98,4 @@ export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
61
98
  const timer = setInterval(() => { typingFn().catch?.(() => {}) }, intervalMs)
62
99
  timer.unref?.()
63
100
  return () => clearInterval(timer)
64
- }
101
+ }
@@ -1,5 +1,19 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from "node:fs"
2
2
  import { dirname } from "node:path"
3
+ import { randomUUID } from "node:crypto"
4
+
5
+ function writeJsonAtomicSync(filePath, data) {
6
+ const dir = dirname(filePath)
7
+ mkdirSync(dir, { recursive: true })
8
+ const tmpPath = `${filePath}.${randomUUID().slice(0, 8)}.tmp`
9
+ try {
10
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8")
11
+ renameSync(tmpPath, filePath)
12
+ } catch (err) {
13
+ try { unlinkSync(tmpPath) } catch {}
14
+ throw err
15
+ }
16
+ }
3
17
 
4
18
  export function createVoicePrefs(filePath) {
5
19
  /** @type {Record<string, boolean>} */
@@ -12,8 +26,9 @@ export function createVoicePrefs(filePath) {
12
26
  }
13
27
  const persist = () => {
14
28
  if (!filePath) return
15
- mkdirSync(dirname(filePath), { recursive: true })
16
- writeFileSync(filePath, JSON.stringify(state, null, 2))
29
+ try {
30
+ writeJsonAtomicSync(filePath, state)
31
+ } catch {}
17
32
  }
18
33
  const key = (userId) => String(Number(userId) || userId || "")
19
34
  return {
@@ -43,4 +58,4 @@ export function shouldSpeakReply({ globalTts, voiceMode, inboundWasVoice, userPr
43
58
  if (voiceMode === "off") return false
44
59
  // mirror: speak if inbound was voice
45
60
  return Boolean(inboundWasVoice)
46
- }
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-messenger-gateway",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
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",