@goodandready/dsh-messenger-gateway 0.3.13 → 0.3.18
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/README.md +6 -0
- package/lib/adapters/telegram.js +46 -3
- package/lib/client.js +1307 -629
- package/lib/content-guard.js +14 -0
- package/lib/gateway.js +81 -45
- package/lib/index.js +49 -15
- package/lib/models.js +4 -3
- package/lib/outbound.js +2 -1
- package/lib/pairing.js +19 -5
- package/lib/photos.js +2 -1
- package/lib/scheduler.js +5 -5
- package/lib/session-ops.js +14 -4
- package/lib/storage-atomic.js +21 -0
- package/lib/stream.js +39 -2
- package/lib/telegram-errors.js +51 -41
- package/lib/voice-prefs.js +19 -4
- package/package.json +2 -5
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize any content value to ContentBlock[].
|
|
3
|
+
* DSH core (dsh-llm) calls content.some() without Array.isArray guard,
|
|
4
|
+
* so we must ensure every message we create or copy has array content.
|
|
5
|
+
* Refs: #51
|
|
6
|
+
*/
|
|
7
|
+
export function ensureContentArray(content) {
|
|
8
|
+
if (Array.isArray(content)) return content
|
|
9
|
+
if (typeof content === 'string') {
|
|
10
|
+
return content ? [{ type: 'text', text: content }] : [{ type: 'text', text: '(пустое сообщение)' }]
|
|
11
|
+
}
|
|
12
|
+
if (content && typeof content === 'object' && content.type) return [content]
|
|
13
|
+
return [{ type: 'text', text: String(content ?? '(пустое сообщение)') }]
|
|
14
|
+
}
|
package/lib/gateway.js
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
createEditScheduler, startTypingHeartbeat,
|
|
34
34
|
} from './stream.js'
|
|
35
35
|
import { isTopicGoneError } from './telegram-errors.js'
|
|
36
|
+
import { ensureContentArray } from './content-guard.js'
|
|
37
|
+
|
|
36
38
|
|
|
37
39
|
function whenIdleWithTimeout(agent, timeoutMs, signal) {
|
|
38
40
|
const idle = agent.whenIdle()
|
|
@@ -174,11 +176,10 @@ export class Gateway {
|
|
|
174
176
|
this.ctx.logger?.warn?.(`dsh-messenger-gateway: ${adapter.name}: ${err.message}`)
|
|
175
177
|
}
|
|
176
178
|
}
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
179
|
+
const rawIdle = Number(this.config.agent?.idleTimeoutMs)
|
|
180
|
+
const idleMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle : 86_400_000
|
|
181
|
+
this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
|
|
182
|
+
this.idleTimer.unref?.()
|
|
182
183
|
this.scheduler.start()
|
|
183
184
|
}
|
|
184
185
|
|
|
@@ -339,8 +340,8 @@ export class Gateway {
|
|
|
339
340
|
return
|
|
340
341
|
}
|
|
341
342
|
if (action.kind === 'done') {
|
|
342
|
-
clearTimeout(pending.timer)
|
|
343
343
|
this.pendingAsks.delete(askToken)
|
|
344
|
+
clearTimeout(pending.timer)
|
|
344
345
|
releaseCallbacks(this.callbackIndex, pending.callbackKeys)
|
|
345
346
|
await cb.answer('OK')
|
|
346
347
|
try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
|
|
@@ -348,8 +349,8 @@ export class Gateway {
|
|
|
348
349
|
return
|
|
349
350
|
}
|
|
350
351
|
if (action.kind === 'cancel') {
|
|
351
|
-
clearTimeout(pending.timer)
|
|
352
352
|
this.pendingAsks.delete(askToken)
|
|
353
|
+
clearTimeout(pending.timer)
|
|
353
354
|
releaseCallbacks(this.callbackIndex, pending.callbackKeys)
|
|
354
355
|
await cb.answer('Отменено')
|
|
355
356
|
try { await cb.editMessage(cb.message?.text || 'Отменено', REMOVE_KEYBOARD) } catch {}
|
|
@@ -357,8 +358,8 @@ export class Gateway {
|
|
|
357
358
|
return
|
|
358
359
|
}
|
|
359
360
|
}
|
|
360
|
-
clearTimeout(pending.timer)
|
|
361
361
|
this.pendingAsks.delete(askToken)
|
|
362
|
+
clearTimeout(pending.timer)
|
|
362
363
|
releaseCallbacks(this.callbackIndex, pending.callbackKeys)
|
|
363
364
|
await cb.answer('OK')
|
|
364
365
|
try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
|
|
@@ -523,7 +524,7 @@ export class Gateway {
|
|
|
523
524
|
steer: true,
|
|
524
525
|
}, undefined)
|
|
525
526
|
chat.agent.followup(createUserMessage({
|
|
526
|
-
content,
|
|
527
|
+
content: ensureContentArray(content),
|
|
527
528
|
source: { kind: 'plugin', plugin: PLUGIN, form: 'steer' },
|
|
528
529
|
}))
|
|
529
530
|
chat.lastUsed = Date.now()
|
|
@@ -533,6 +534,7 @@ export class Gateway {
|
|
|
533
534
|
|
|
534
535
|
// Mark busy BEFORE yielding to the poll loop, otherwise steer/stop never see an active turn.
|
|
535
536
|
chat.turnActive = true
|
|
537
|
+
try { chat.abort?.abort?.() } catch {}
|
|
536
538
|
chat.abort = new AbortController()
|
|
537
539
|
const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
|
|
538
540
|
chat.busy = run.catch(() => {})
|
|
@@ -565,30 +567,37 @@ export class Gateway {
|
|
|
565
567
|
if (cmd === '/start') return reply('Шлюз подключён. Пишите сообщение агенту. /help — команды.', { replyMarkup: REMOVE_REPLY_KEYBOARD })
|
|
566
568
|
if (cmd === '/help') {
|
|
567
569
|
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
|
-
'/
|
|
570
|
+
'📖 <b>Команды бота:</b>',
|
|
571
|
+
'',
|
|
572
|
+
'💬 <b>Диалог:</b>',
|
|
573
|
+
'• /help — эта справка',
|
|
574
|
+
'• /new — новая сессия',
|
|
575
|
+
'• /stop — прервать текущий ответ',
|
|
576
|
+
'• /model — интерактивный выбор модели (/model list)',
|
|
577
|
+
'• /role [name] — персоны и роли агента (/role list)',
|
|
578
|
+
'• /rewind [N] — откат последних N ходов',
|
|
579
|
+
'• /fork — форк сессии в новую ветку',
|
|
580
|
+
'• /export — выгрузка истории в Markdown',
|
|
581
|
+
'',
|
|
582
|
+
'🛠️ <b>Инструменты и файлы:</b>',
|
|
583
|
+
'• /skills / /tools — список активных инструментов',
|
|
584
|
+
'• /files [dir] — проводник рабочей папки',
|
|
585
|
+
'• /get <path> — скачать файл в Telegram',
|
|
586
|
+
'• /remind <время> <текст> — напоминание (/remind 10m текст)',
|
|
587
|
+
'',
|
|
588
|
+
'⚙️ <b>Настройки:</b>',
|
|
589
|
+
'• /status — статус шлюза и модели',
|
|
590
|
+
'• /top — системные ресурсы (RAM, uptime)',
|
|
591
|
+
'• /keyboard on|off — быстрые кнопки',
|
|
592
|
+
'• /voice on|off|status — голосовые ответы',
|
|
593
|
+
'• /tts on|off|status — озвучка в этом чате',
|
|
594
|
+
'• /mute / /unmute — заглушить уведомления в этот чат',
|
|
595
|
+
'',
|
|
596
|
+
'🔒 <b>Доступ и каналы:</b>',
|
|
597
|
+
'• /whoami — ваш Telegram id',
|
|
598
|
+
'• /pair CODE — одобрить код сопряжения',
|
|
599
|
+
'• /sethome [name] — привязать домашний чат',
|
|
600
|
+
'• /setalert — назначить канал алертов',
|
|
592
601
|
].join('\n'))
|
|
593
602
|
}
|
|
594
603
|
if (cmd === '/role' || cmd === '/persona') {
|
|
@@ -619,9 +628,9 @@ export class Gateway {
|
|
|
619
628
|
return reply(`🎭 Роль изменена на: ${persona.icon} <b>${persona.name}</b>\n${persona.description}`)
|
|
620
629
|
}
|
|
621
630
|
if (cmd === '/skills' || cmd === '/tools') {
|
|
622
|
-
const
|
|
623
|
-
if (
|
|
624
|
-
for (const [name, t] of
|
|
631
|
+
const tools = this.ctx.get?.('tools') || this.ctx.tools
|
|
632
|
+
if (tools?.tools) {
|
|
633
|
+
for (const [name, t] of tools.tools.entries()) {
|
|
625
634
|
toolsList.push(`• <b>${name}</b>: ${t.description || '(нет описания)'}`)
|
|
626
635
|
}
|
|
627
636
|
}
|
|
@@ -649,7 +658,6 @@ export class Gateway {
|
|
|
649
658
|
mime: 'text/markdown',
|
|
650
659
|
kind: 'document',
|
|
651
660
|
bytes: buffer,
|
|
652
|
-
dataBase64: buffer.toString('base64'),
|
|
653
661
|
}
|
|
654
662
|
return reply({ text: `📄 Экспорт диалога (${messagesCount} сообщений):`, files: [file] })
|
|
655
663
|
} catch (err) {
|
|
@@ -666,7 +674,8 @@ export class Gateway {
|
|
|
666
674
|
if (!res.removed) {
|
|
667
675
|
return reply('В истории сессии нет сообщений для отката.')
|
|
668
676
|
}
|
|
669
|
-
|
|
677
|
+
const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
|
|
678
|
+
try { await sessions?.flush(chat.agent.session) } catch {}
|
|
670
679
|
return reply(`⏪ Откачено сообщений: ${res.removed}. Осталось в контексте: ${res.remaining}.`)
|
|
671
680
|
}
|
|
672
681
|
if (cmd === '/fork') {
|
|
@@ -676,11 +685,14 @@ export class Gateway {
|
|
|
676
685
|
}
|
|
677
686
|
try {
|
|
678
687
|
const oldSession = chat.agent.session
|
|
679
|
-
const oldMessages = Array.isArray(oldSession.messages)
|
|
688
|
+
const oldMessages = Array.isArray(oldSession.messages)
|
|
689
|
+
? oldSession.messages.map(m => ({ ...m, content: ensureContentArray(m.content) }))
|
|
690
|
+
: []
|
|
680
691
|
const newChat = await this.createChat(key, input)
|
|
681
692
|
if (newChat.agent?.session && oldMessages.length) {
|
|
682
693
|
newChat.agent.session.messages = oldMessages
|
|
683
|
-
|
|
694
|
+
const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
|
|
695
|
+
try { await sessions?.flush(newChat.agent.session) } catch {}
|
|
684
696
|
}
|
|
685
697
|
this.chats.set(key, newChat)
|
|
686
698
|
return reply(`🔀 Создан форк сессии!\nСтарая сессия: ${oldSession.id}\nНовая активная сессия: ${newChat.agent.session.id}\nКонтекст сохранён (${oldMessages.length} сообщений).`)
|
|
@@ -1068,7 +1080,8 @@ export class Gateway {
|
|
|
1068
1080
|
const agentCfg = this.config.agent || {}
|
|
1069
1081
|
const cwd = agentCfg.cwd || process.cwd()
|
|
1070
1082
|
const self = this
|
|
1071
|
-
const
|
|
1083
|
+
const agents = this.ctx.get?.('agents') || this.ctx.agents
|
|
1084
|
+
const handle = await agents.create({
|
|
1072
1085
|
sessionId: SessionId(`msgw-${randomUUID()}`),
|
|
1073
1086
|
meta: { cwd },
|
|
1074
1087
|
agentOptions: { provider, model },
|
|
@@ -1218,7 +1231,7 @@ export class Gateway {
|
|
|
1218
1231
|
|
|
1219
1232
|
const content = await this.buildUserContent(input, signal)
|
|
1220
1233
|
chat.agent.followup(createUserMessage({
|
|
1221
|
-
content,
|
|
1234
|
+
content: ensureContentArray(content),
|
|
1222
1235
|
source: { kind: 'plugin', plugin: PLUGIN, form: 'relay' },
|
|
1223
1236
|
}))
|
|
1224
1237
|
const turnTimeoutMs = Number(this.config.agent?.turnTimeoutMs) || 600_000
|
|
@@ -1230,7 +1243,8 @@ export class Gateway {
|
|
|
1230
1243
|
else return reply('Прервано.')
|
|
1231
1244
|
return
|
|
1232
1245
|
}
|
|
1233
|
-
|
|
1246
|
+
const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
|
|
1247
|
+
await sessions?.flush?.(chat.agent.session)
|
|
1234
1248
|
if (progress) try { await progress.remove() } catch {}
|
|
1235
1249
|
progress = null
|
|
1236
1250
|
if (typeof react === 'function') react('').catch?.(() => {})
|
|
@@ -1318,8 +1332,8 @@ export class Gateway {
|
|
|
1318
1332
|
}
|
|
1319
1333
|
|
|
1320
1334
|
reapIdle() {
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1335
|
+
const rawTimeout = Number(this.config.agent?.idleTimeoutMs)
|
|
1336
|
+
const timeout = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 86_400_000
|
|
1323
1337
|
const now = Date.now()
|
|
1324
1338
|
for (const [key, chat] of this.chats) {
|
|
1325
1339
|
if (!chat.turnActive && now - chat.lastUsed > timeout) {
|
|
@@ -1347,6 +1361,26 @@ export class Gateway {
|
|
|
1347
1361
|
return this.pairing.rejectCode(code)
|
|
1348
1362
|
}
|
|
1349
1363
|
|
|
1364
|
+
async probeTelegram(timeoutMs = 10000) {
|
|
1365
|
+
const adapter = this.getAdapter('telegram')
|
|
1366
|
+
if (!adapter) {
|
|
1367
|
+
return { ok: false, error: 'Telegram adapter not initialized' }
|
|
1368
|
+
}
|
|
1369
|
+
if (typeof adapter.probeHealth === 'function') {
|
|
1370
|
+
return adapter.probeHealth(timeoutMs)
|
|
1371
|
+
}
|
|
1372
|
+
return { ok: false, error: 'probeHealth not implemented on adapter' }
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
getBotInfo() {
|
|
1376
|
+
const adapter = this.getAdapter('telegram')
|
|
1377
|
+
return {
|
|
1378
|
+
botId: adapter?.botId || 0,
|
|
1379
|
+
botUsername: adapter?.botUsername || '',
|
|
1380
|
+
pollingConflict: Boolean(adapter?.pollingConflict),
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1350
1384
|
async messengerAskFromAgent(agent, payload, timeoutMs) {
|
|
1351
1385
|
const sessionId = String(agent?.session?.id || '')
|
|
1352
1386
|
const key = this.sessionToChat.get(sessionId)
|
|
@@ -1366,6 +1400,8 @@ export class Gateway {
|
|
|
1366
1400
|
send: (target, payload) => this.messengerSend(target, payload),
|
|
1367
1401
|
ask: (target, payload, timeoutMs) => this.messengerAsk(target, payload, timeoutMs),
|
|
1368
1402
|
progress: (target, payload) => this.messengerProgress(target, payload),
|
|
1403
|
+
probeTelegram: (timeoutMs) => this.probeTelegram(timeoutMs),
|
|
1404
|
+
getBotInfo: () => this.getBotInfo(),
|
|
1369
1405
|
}
|
|
1370
1406
|
}
|
|
1371
1407
|
}
|
package/lib/index.js
CHANGED
|
@@ -78,7 +78,7 @@ function publicConfig(cfg) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
function registerMessengerRoute(ctx, getGw, path, action) {
|
|
81
|
-
ctx.effect(() => ctx.webServer.register({
|
|
81
|
+
ctx.effect(() => (ctx.get?.('webServer') || ctx.webServer).register({
|
|
82
82
|
kind: 'exact', path,
|
|
83
83
|
handler: async (req, res) => {
|
|
84
84
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
@@ -112,6 +112,7 @@ export function apply(ctx, config) {
|
|
|
112
112
|
let source = () => entry
|
|
113
113
|
let settingsApi
|
|
114
114
|
const turnStarts = new Map()
|
|
115
|
+
const getWebServer = () => ctx.get?.('webServer') || ctx.webServer
|
|
115
116
|
|
|
116
117
|
const sync = () => {
|
|
117
118
|
if (gateway) { gateway.stop(); gateway = undefined }
|
|
@@ -154,8 +155,9 @@ export function apply(ctx, config) {
|
|
|
154
155
|
gateway.start().catch((err) => ctx.logger?.warn?.(`dsh-messenger-gateway: ${err.message}`))
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
const settings = ctx.get?.('settings') || ctx.settings
|
|
159
|
+
if (settings?.register) {
|
|
160
|
+
settingsApi = settings.register(SETTINGS_NAMESPACE, Config, { base: config || {} })
|
|
159
161
|
source = () => resolveConfig(settingsApi.get() ?? config ?? {})
|
|
160
162
|
ctx.effect(() => settingsApi.watch(sync), 'dsh-messenger-gateway: settings')
|
|
161
163
|
}
|
|
@@ -165,8 +167,9 @@ export function apply(ctx, config) {
|
|
|
165
167
|
ctx.effect(() => ctx.provide('messenger', createMessengerService(getGw)), 'dsh-messenger-gateway: messenger service')
|
|
166
168
|
|
|
167
169
|
// Agent tool: inline buttons in the telegram chat bound to this msgw session
|
|
168
|
-
|
|
169
|
-
|
|
170
|
+
const tools = ctx.get?.('tools') || ctx.tools
|
|
171
|
+
if (tools?.register) {
|
|
172
|
+
ctx.effect(() => tools.register(defineTool({
|
|
170
173
|
name: 'messenger_ask',
|
|
171
174
|
description:
|
|
172
175
|
'Ask the Telegram user a single or multiple-choice question with inline buttons/checkboxes and wait for their choice. '
|
|
@@ -267,11 +270,13 @@ export function apply(ctx, config) {
|
|
|
267
270
|
.catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
|
|
268
271
|
}), 'dsh-messenger-gateway: notify bridge')
|
|
269
272
|
|
|
270
|
-
ctx.effect(() =>
|
|
273
|
+
ctx.effect(() => getWebServer().register({
|
|
271
274
|
kind: 'exact', path: '/dsh-messenger-gateway/status',
|
|
272
275
|
handler: async (req, res) => {
|
|
273
276
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
274
277
|
const gw = getGw()
|
|
278
|
+
const botInfo = gw?.getBotInfo?.() || {}
|
|
279
|
+
const uptimeSec = gw?.stats?.startedAt ? Math.max(0, Math.round((Date.now() - gw.stats.startedAt) / 1000)) : 0
|
|
275
280
|
writeJson(res, 200, {
|
|
276
281
|
ok: true,
|
|
277
282
|
running: Boolean(gw),
|
|
@@ -279,15 +284,44 @@ export function apply(ctx, config) {
|
|
|
279
284
|
activeChats: gw?.messenger.activeChats() || 0,
|
|
280
285
|
ttsEnabled: Boolean(source().tts?.enabled),
|
|
281
286
|
messengerService: 'messenger',
|
|
282
|
-
home:
|
|
283
|
-
homes:
|
|
287
|
+
home: gw?.messenger.home?.() || null,
|
|
288
|
+
homes: gw?.messenger.homes?.() || [],
|
|
284
289
|
pairingPending: gw?.messenger.pairingPending?.()?.length || 0,
|
|
290
|
+
botId: botInfo.botId || 0,
|
|
291
|
+
botUsername: botInfo.botUsername || '',
|
|
292
|
+
pollingConflict: Boolean(botInfo.pollingConflict),
|
|
293
|
+
uptimeSec,
|
|
294
|
+
stats: {
|
|
295
|
+
sent: gw?.stats?.sent || 0,
|
|
296
|
+
errors: gw?.stats?.errors || 0,
|
|
297
|
+
startedAt: gw?.stats?.startedAt || 0,
|
|
298
|
+
},
|
|
285
299
|
config: publicConfig(source()),
|
|
286
300
|
})
|
|
287
301
|
},
|
|
288
302
|
}), 'dsh-messenger-gateway: status')
|
|
289
303
|
|
|
290
|
-
ctx.effect(() =>
|
|
304
|
+
ctx.effect(() => getWebServer().register({
|
|
305
|
+
kind: 'exact', path: '/dsh-messenger-gateway/smoke',
|
|
306
|
+
handler: async (req, res) => {
|
|
307
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
308
|
+
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
309
|
+
const gw = getGw()
|
|
310
|
+
if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
|
|
311
|
+
let payload = {}
|
|
312
|
+
try {
|
|
313
|
+
const raw = (await readBody(req)).toString('utf8').trim()
|
|
314
|
+
if (raw) payload = JSON.parse(raw)
|
|
315
|
+
} catch {
|
|
316
|
+
return writeJson(res, 400, { ok: false, error: 'invalid json' })
|
|
317
|
+
}
|
|
318
|
+
const timeoutMs = Number(payload.timeoutMs) || 10000
|
|
319
|
+
const result = await gw.probeTelegram(timeoutMs)
|
|
320
|
+
writeJson(res, result.ok ? 200 : 502, result)
|
|
321
|
+
},
|
|
322
|
+
}), 'dsh-messenger-gateway: smoke test')
|
|
323
|
+
|
|
324
|
+
ctx.effect(() => getWebServer().register({
|
|
291
325
|
kind: 'exact', path: '/dsh-messenger-gateway/messenger',
|
|
292
326
|
handler: async (req, res) => {
|
|
293
327
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
@@ -295,7 +329,7 @@ export function apply(ctx, config) {
|
|
|
295
329
|
},
|
|
296
330
|
}), 'dsh-messenger-gateway: messenger schema')
|
|
297
331
|
|
|
298
|
-
ctx.effect(() =>
|
|
332
|
+
ctx.effect(() => getWebServer().register({
|
|
299
333
|
kind: 'exact', path: '/dsh-messenger-gateway/events',
|
|
300
334
|
handler: async (req, res) => {
|
|
301
335
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
@@ -334,7 +368,7 @@ export function apply(ctx, config) {
|
|
|
334
368
|
},
|
|
335
369
|
}), 'dsh-messenger-gateway: webhook events')
|
|
336
370
|
|
|
337
|
-
ctx.effect(() =>
|
|
371
|
+
ctx.effect(() => getWebServer().register({
|
|
338
372
|
kind: 'exact', path: '/dsh-messenger-gateway/config',
|
|
339
373
|
handler: async (req, res) => {
|
|
340
374
|
if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
|
|
@@ -370,7 +404,7 @@ export function apply(ctx, config) {
|
|
|
370
404
|
},
|
|
371
405
|
}), 'dsh-messenger-gateway: config')
|
|
372
406
|
|
|
373
|
-
ctx.effect(() =>
|
|
407
|
+
ctx.effect(() => getWebServer().register({
|
|
374
408
|
kind: 'exact', path: '/dsh-messenger-gateway/pairing',
|
|
375
409
|
handler: async (req, res) => {
|
|
376
410
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
@@ -385,7 +419,7 @@ export function apply(ctx, config) {
|
|
|
385
419
|
},
|
|
386
420
|
}), 'dsh-messenger-gateway: pairing list')
|
|
387
421
|
|
|
388
|
-
ctx.effect(() =>
|
|
422
|
+
ctx.effect(() => getWebServer().register({
|
|
389
423
|
kind: 'exact', path: '/dsh-messenger-gateway/pairing/approve',
|
|
390
424
|
handler: async (req, res) => {
|
|
391
425
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
@@ -399,7 +433,7 @@ export function apply(ctx, config) {
|
|
|
399
433
|
},
|
|
400
434
|
}), 'dsh-messenger-gateway: pairing approve')
|
|
401
435
|
|
|
402
|
-
ctx.effect(() =>
|
|
436
|
+
ctx.effect(() => getWebServer().register({
|
|
403
437
|
kind: 'exact', path: '/dsh-messenger-gateway/pairing/reject',
|
|
404
438
|
handler: async (req, res) => {
|
|
405
439
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
@@ -418,7 +452,7 @@ export function apply(ctx, config) {
|
|
|
418
452
|
return p || '/dsh-messenger-gateway/telegram/webhook'
|
|
419
453
|
}
|
|
420
454
|
|
|
421
|
-
ctx.effect(() =>
|
|
455
|
+
ctx.effect(() => getWebServer().register({
|
|
422
456
|
kind: 'exact', path: webhookPath(),
|
|
423
457
|
handler: async (req, res) => {
|
|
424
458
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
package/lib/models.js
CHANGED
|
@@ -7,14 +7,15 @@ export async function listModelCatalog(ctx, fallback = {}) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
// 1. Try ctx.llm if available
|
|
10
|
-
|
|
10
|
+
const llm = ctx?.get?.('llm') || ctx?.llm
|
|
11
|
+
if (llm?.listProviders) {
|
|
11
12
|
try {
|
|
12
|
-
const providers = await
|
|
13
|
+
const providers = await llm.listProviders()
|
|
13
14
|
for (const p of providers || []) {
|
|
14
15
|
const pId = p.id || p
|
|
15
16
|
result.providers.push({ id: pId, name: p.name || pId })
|
|
16
17
|
try {
|
|
17
|
-
const models = await
|
|
18
|
+
const models = await llm.listModels(pId)
|
|
18
19
|
result.modelsByProvider.set(
|
|
19
20
|
pId,
|
|
20
21
|
(models || []).map((m) => ({ id: m.id || m, name: m.name || m.id || m }))
|
package/lib/outbound.js
CHANGED
|
@@ -62,7 +62,8 @@ export function fileNameForRef(ref) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export async function readAttachmentBytes(ctx, ref) {
|
|
65
|
-
const
|
|
65
|
+
const attachments = ctx.get?.('attachments') || ctx.attachments
|
|
66
|
+
const stored = await attachments.readImage(ref)
|
|
66
67
|
return {
|
|
67
68
|
bytes: stored.data,
|
|
68
69
|
mime: ref.mediaType || stored.ref?.mediaType || 'image/png',
|
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/photos.js
CHANGED
|
@@ -12,7 +12,8 @@ export async function attachInboundPhoto(ctx, att, opts = {}) {
|
|
|
12
12
|
if (maxBytes > 0 && bytes.length > maxBytes) {
|
|
13
13
|
throw new Error(`image too large (${bytes.length} bytes, max ${maxBytes})`)
|
|
14
14
|
}
|
|
15
|
-
const
|
|
15
|
+
const attachments = ctx.get?.('attachments') || ctx.attachments
|
|
16
|
+
const ref = await attachments.saveImage({
|
|
16
17
|
data: bytes,
|
|
17
18
|
mediaType: att.mime || 'image/jpeg',
|
|
18
19
|
...(att.name ? { name: att.name } : {}),
|
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
|
+
}
|
package/lib/session-ops.js
CHANGED
|
@@ -87,9 +87,19 @@ export function rewindSession(session, turnsCount = 1) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
const
|
|
90
|
+
const keepCount = Math.max(0, messages.length - toRemove)
|
|
91
|
+
const kept = messages.slice(0, keepCount).map(m => {
|
|
92
|
+
// Only wrap non-string invalid/nullish/object content to array
|
|
93
|
+
if (m && m.content && typeof m.content === 'object' && !Array.isArray(m.content) && m.content.type) {
|
|
94
|
+
return { ...m, content: [m.content] }
|
|
95
|
+
}
|
|
96
|
+
return m
|
|
97
|
+
})
|
|
98
|
+
const removedCount = messages.length - keepCount
|
|
99
|
+
session.messages = kept
|
|
100
|
+
|
|
91
101
|
return {
|
|
92
|
-
removed:
|
|
93
|
-
remaining: messages.length,
|
|
102
|
+
removed: removedCount,
|
|
103
|
+
remaining: session.messages.length,
|
|
94
104
|
}
|
|
95
|
-
}
|
|
105
|
+
}
|
|
@@ -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
|
+
}
|