@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 +139 -52
- package/lib/gateway.js +37 -31
- package/lib/pairing.js +19 -5
- package/lib/scheduler.js +5 -5
- package/lib/storage-atomic.js +21 -0
- package/lib/stream.js +39 -2
- package/lib/voice-prefs.js +19 -4
- package/package.json +1 -1
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
|
-
//
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 (
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
'
|
|
570
|
-
'
|
|
571
|
-
'/
|
|
572
|
-
'/
|
|
573
|
-
'/
|
|
574
|
-
'/model —
|
|
575
|
-
'/role [name] — персоны и роли агента (/role list)',
|
|
576
|
-
'
|
|
577
|
-
'/fork —
|
|
578
|
-
'/export — выгрузка истории
|
|
579
|
-
'
|
|
580
|
-
'
|
|
581
|
-
'/
|
|
582
|
-
'/
|
|
583
|
-
'/
|
|
584
|
-
'/
|
|
585
|
-
'
|
|
586
|
-
'
|
|
587
|
-
'/
|
|
588
|
-
'/
|
|
589
|
-
'/
|
|
590
|
-
'
|
|
591
|
-
'/
|
|
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
|
|
1322
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/voice-prefs.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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.
|
|
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",
|