@goodandready/dsh-messenger-gateway 0.3.14 → 0.3.19
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 +122 -22
- package/lib/client.js +1312 -629
- package/lib/content-guard.js +14 -0
- package/lib/gateway.js +47 -18
- package/lib/index.js +49 -15
- package/lib/models.js +4 -3
- package/lib/outbound.js +2 -1
- package/lib/photos.js +2 -1
- package/lib/session-ops.js +14 -4
- package/lib/stream.js +114 -101
- package/lib/telegram-errors.js +10 -0
- 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()
|
|
@@ -338,8 +340,8 @@ export class Gateway {
|
|
|
338
340
|
return
|
|
339
341
|
}
|
|
340
342
|
if (action.kind === 'done') {
|
|
341
|
-
clearTimeout(pending.timer)
|
|
342
343
|
this.pendingAsks.delete(askToken)
|
|
344
|
+
clearTimeout(pending.timer)
|
|
343
345
|
releaseCallbacks(this.callbackIndex, pending.callbackKeys)
|
|
344
346
|
await cb.answer('OK')
|
|
345
347
|
try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
|
|
@@ -347,8 +349,8 @@ export class Gateway {
|
|
|
347
349
|
return
|
|
348
350
|
}
|
|
349
351
|
if (action.kind === 'cancel') {
|
|
350
|
-
clearTimeout(pending.timer)
|
|
351
352
|
this.pendingAsks.delete(askToken)
|
|
353
|
+
clearTimeout(pending.timer)
|
|
352
354
|
releaseCallbacks(this.callbackIndex, pending.callbackKeys)
|
|
353
355
|
await cb.answer('Отменено')
|
|
354
356
|
try { await cb.editMessage(cb.message?.text || 'Отменено', REMOVE_KEYBOARD) } catch {}
|
|
@@ -356,8 +358,8 @@ export class Gateway {
|
|
|
356
358
|
return
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
|
-
clearTimeout(pending.timer)
|
|
360
361
|
this.pendingAsks.delete(askToken)
|
|
362
|
+
clearTimeout(pending.timer)
|
|
361
363
|
releaseCallbacks(this.callbackIndex, pending.callbackKeys)
|
|
362
364
|
await cb.answer('OK')
|
|
363
365
|
try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
|
|
@@ -522,7 +524,7 @@ export class Gateway {
|
|
|
522
524
|
steer: true,
|
|
523
525
|
}, undefined)
|
|
524
526
|
chat.agent.followup(createUserMessage({
|
|
525
|
-
content,
|
|
527
|
+
content: ensureContentArray(content),
|
|
526
528
|
source: { kind: 'plugin', plugin: PLUGIN, form: 'steer' },
|
|
527
529
|
}))
|
|
528
530
|
chat.lastUsed = Date.now()
|
|
@@ -532,6 +534,7 @@ export class Gateway {
|
|
|
532
534
|
|
|
533
535
|
// Mark busy BEFORE yielding to the poll loop, otherwise steer/stop never see an active turn.
|
|
534
536
|
chat.turnActive = true
|
|
537
|
+
try { chat.abort?.abort?.() } catch {}
|
|
535
538
|
chat.abort = new AbortController()
|
|
536
539
|
const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
|
|
537
540
|
chat.busy = run.catch(() => {})
|
|
@@ -625,9 +628,9 @@ export class Gateway {
|
|
|
625
628
|
return reply(`🎭 Роль изменена на: ${persona.icon} <b>${persona.name}</b>\n${persona.description}`)
|
|
626
629
|
}
|
|
627
630
|
if (cmd === '/skills' || cmd === '/tools') {
|
|
628
|
-
const
|
|
629
|
-
if (
|
|
630
|
-
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()) {
|
|
631
634
|
toolsList.push(`• <b>${name}</b>: ${t.description || '(нет описания)'}`)
|
|
632
635
|
}
|
|
633
636
|
}
|
|
@@ -655,7 +658,6 @@ export class Gateway {
|
|
|
655
658
|
mime: 'text/markdown',
|
|
656
659
|
kind: 'document',
|
|
657
660
|
bytes: buffer,
|
|
658
|
-
dataBase64: buffer.toString('base64'),
|
|
659
661
|
}
|
|
660
662
|
return reply({ text: `📄 Экспорт диалога (${messagesCount} сообщений):`, files: [file] })
|
|
661
663
|
} catch (err) {
|
|
@@ -672,7 +674,8 @@ export class Gateway {
|
|
|
672
674
|
if (!res.removed) {
|
|
673
675
|
return reply('В истории сессии нет сообщений для отката.')
|
|
674
676
|
}
|
|
675
|
-
|
|
677
|
+
const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
|
|
678
|
+
try { await sessions?.flush(chat.agent.session) } catch {}
|
|
676
679
|
return reply(`⏪ Откачено сообщений: ${res.removed}. Осталось в контексте: ${res.remaining}.`)
|
|
677
680
|
}
|
|
678
681
|
if (cmd === '/fork') {
|
|
@@ -682,11 +685,14 @@ export class Gateway {
|
|
|
682
685
|
}
|
|
683
686
|
try {
|
|
684
687
|
const oldSession = chat.agent.session
|
|
685
|
-
const oldMessages = Array.isArray(oldSession.messages)
|
|
688
|
+
const oldMessages = Array.isArray(oldSession.messages)
|
|
689
|
+
? oldSession.messages.map(m => ({ ...m, content: ensureContentArray(m.content) }))
|
|
690
|
+
: []
|
|
686
691
|
const newChat = await this.createChat(key, input)
|
|
687
692
|
if (newChat.agent?.session && oldMessages.length) {
|
|
688
693
|
newChat.agent.session.messages = oldMessages
|
|
689
|
-
|
|
694
|
+
const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
|
|
695
|
+
try { await sessions?.flush(newChat.agent.session) } catch {}
|
|
690
696
|
}
|
|
691
697
|
this.chats.set(key, newChat)
|
|
692
698
|
return reply(`🔀 Создан форк сессии!\nСтарая сессия: ${oldSession.id}\nНовая активная сессия: ${newChat.agent.session.id}\nКонтекст сохранён (${oldMessages.length} сообщений).`)
|
|
@@ -1074,7 +1080,8 @@ export class Gateway {
|
|
|
1074
1080
|
const agentCfg = this.config.agent || {}
|
|
1075
1081
|
const cwd = agentCfg.cwd || process.cwd()
|
|
1076
1082
|
const self = this
|
|
1077
|
-
const
|
|
1083
|
+
const agents = this.ctx.get?.('agents') || this.ctx.agents
|
|
1084
|
+
const handle = await agents.create({
|
|
1078
1085
|
sessionId: SessionId(`msgw-${randomUUID()}`),
|
|
1079
1086
|
meta: { cwd },
|
|
1080
1087
|
agentOptions: { provider, model },
|
|
@@ -1192,12 +1199,13 @@ export class Gateway {
|
|
|
1192
1199
|
if (typeof react === 'function' && tg.reactionsEnabled !== false) {
|
|
1193
1200
|
react('👀').catch?.(() => {})
|
|
1194
1201
|
}
|
|
1195
|
-
if (
|
|
1202
|
+
if (typeof typing === 'function') stopTyping = startTypingHeartbeat(typing, 4000)
|
|
1196
1203
|
if (streaming) {
|
|
1197
1204
|
try {
|
|
1198
1205
|
stream = await startStream()
|
|
1199
1206
|
scheduler = createEditScheduler((text) => stream.edit(text), Number(tg.streamEditIntervalMs) || 1200)
|
|
1200
1207
|
collector.onStream = (text, toolName) => {
|
|
1208
|
+
if (text) stopTyping()
|
|
1201
1209
|
if (!progressEnabled && !text) return
|
|
1202
1210
|
scheduler.push(buildStreamPreview(text, progressEnabled ? toolName : ''))
|
|
1203
1211
|
}
|
|
@@ -1216,15 +1224,13 @@ export class Gateway {
|
|
|
1216
1224
|
} catch (e) {
|
|
1217
1225
|
this.ctx.logger?.warn?.(`progress start: ${e.message}`)
|
|
1218
1226
|
progress = null
|
|
1219
|
-
if (typing) await typing()
|
|
1220
1227
|
}
|
|
1221
|
-
} else if (typing) {
|
|
1222
|
-
await typing()
|
|
1223
1228
|
}
|
|
1224
1229
|
|
|
1230
|
+
|
|
1225
1231
|
const content = await this.buildUserContent(input, signal)
|
|
1226
1232
|
chat.agent.followup(createUserMessage({
|
|
1227
|
-
content,
|
|
1233
|
+
content: ensureContentArray(content),
|
|
1228
1234
|
source: { kind: 'plugin', plugin: PLUGIN, form: 'relay' },
|
|
1229
1235
|
}))
|
|
1230
1236
|
const turnTimeoutMs = Number(this.config.agent?.turnTimeoutMs) || 600_000
|
|
@@ -1236,7 +1242,8 @@ export class Gateway {
|
|
|
1236
1242
|
else return reply('Прервано.')
|
|
1237
1243
|
return
|
|
1238
1244
|
}
|
|
1239
|
-
|
|
1245
|
+
const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
|
|
1246
|
+
await sessions?.flush?.(chat.agent.session)
|
|
1240
1247
|
if (progress) try { await progress.remove() } catch {}
|
|
1241
1248
|
progress = null
|
|
1242
1249
|
if (typeof react === 'function') react('').catch?.(() => {})
|
|
@@ -1353,6 +1360,26 @@ export class Gateway {
|
|
|
1353
1360
|
return this.pairing.rejectCode(code)
|
|
1354
1361
|
}
|
|
1355
1362
|
|
|
1363
|
+
async probeTelegram(timeoutMs = 10000) {
|
|
1364
|
+
const adapter = this.getAdapter('telegram')
|
|
1365
|
+
if (!adapter) {
|
|
1366
|
+
return { ok: false, error: 'Telegram adapter not initialized' }
|
|
1367
|
+
}
|
|
1368
|
+
if (typeof adapter.probeHealth === 'function') {
|
|
1369
|
+
return adapter.probeHealth(timeoutMs)
|
|
1370
|
+
}
|
|
1371
|
+
return { ok: false, error: 'probeHealth not implemented on adapter' }
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
getBotInfo() {
|
|
1375
|
+
const adapter = this.getAdapter('telegram')
|
|
1376
|
+
return {
|
|
1377
|
+
botId: adapter?.botId || 0,
|
|
1378
|
+
botUsername: adapter?.botUsername || '',
|
|
1379
|
+
pollingConflict: Boolean(adapter?.pollingConflict),
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1356
1383
|
async messengerAskFromAgent(agent, payload, timeoutMs) {
|
|
1357
1384
|
const sessionId = String(agent?.session?.id || '')
|
|
1358
1385
|
const key = this.sessionToChat.get(sessionId)
|
|
@@ -1372,6 +1399,8 @@ export class Gateway {
|
|
|
1372
1399
|
send: (target, payload) => this.messengerSend(target, payload),
|
|
1373
1400
|
ask: (target, payload, timeoutMs) => this.messengerAsk(target, payload, timeoutMs),
|
|
1374
1401
|
progress: (target, payload) => this.messengerProgress(target, payload),
|
|
1402
|
+
probeTelegram: (timeoutMs) => this.probeTelegram(timeoutMs),
|
|
1403
|
+
getBotInfo: () => this.getBotInfo(),
|
|
1375
1404
|
}
|
|
1376
1405
|
}
|
|
1377
1406
|
}
|
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/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/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
|
+
}
|
package/lib/stream.js
CHANGED
|
@@ -1,101 +1,114 @@
|
|
|
1
|
-
/** Helpers for Telegram streaming edits + progress. */
|
|
2
|
-
|
|
3
|
-
export function extractTextDelta(chunk) {
|
|
4
|
-
if (!chunk || typeof chunk !== 'object') return ''
|
|
5
|
-
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') return chunk.text
|
|
6
|
-
return ''
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function extractToolName(eventData) {
|
|
10
|
-
const name = eventData?.tool?.name || eventData?.toolName || eventData?.name
|
|
11
|
-
return name ? String(name) : ''
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function formatProgressLine(toolName) {
|
|
15
|
-
if (!toolName) return '⏳ Думаю…'
|
|
16
|
-
return `🔧 ${toolName}…`
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
|
|
20
|
-
const body = String(streamText || '').trimEnd()
|
|
21
|
-
const head = toolName ? `${formatProgressLine(toolName)}\n\n` : ''
|
|
22
|
-
const combined = head + (body || '…')
|
|
23
|
-
if (combined.length <= maxLen) return combined
|
|
24
|
-
return combined.slice(0, maxLen - 1) + '…'
|
|
25
|
-
}
|
|
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
|
-
|
|
35
|
-
export function createEditScheduler(editFn, intervalMs = 1200) {
|
|
36
|
-
let pending = null
|
|
37
|
-
let timer = null
|
|
38
|
-
let lastSent = ''
|
|
39
|
-
let inFlight = Promise.resolve()
|
|
40
|
-
let retryDelayMs = 0
|
|
41
|
-
|
|
42
|
-
const flush = () => {
|
|
43
|
-
timer = null
|
|
44
|
-
if (pending === null || pending === lastSent) return
|
|
45
|
-
const text = pending
|
|
46
|
-
inFlight = inFlight.then(async () => {
|
|
47
|
-
try {
|
|
48
|
-
if (retryDelayMs > 0) {
|
|
49
|
-
const delay = retryDelayMs
|
|
50
|
-
retryDelayMs = 0
|
|
51
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
52
|
-
}
|
|
53
|
-
await editFn(text)
|
|
54
|
-
lastSent = text
|
|
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
|
-
}
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
push(text) {
|
|
71
|
-
pending = text
|
|
72
|
-
if (timer) return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
1
|
+
/** Helpers for Telegram streaming edits + progress. */
|
|
2
|
+
|
|
3
|
+
export function extractTextDelta(chunk) {
|
|
4
|
+
if (!chunk || typeof chunk !== 'object') return ''
|
|
5
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') return chunk.text
|
|
6
|
+
return ''
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function extractToolName(eventData) {
|
|
10
|
+
const name = eventData?.tool?.name || eventData?.toolName || eventData?.name
|
|
11
|
+
return name ? String(name) : ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatProgressLine(toolName) {
|
|
15
|
+
if (!toolName) return '⏳ Думаю…'
|
|
16
|
+
return `🔧 ${toolName}…`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildStreamPreview(streamText, toolName, maxLen = 3500) {
|
|
20
|
+
const body = String(streamText || '').trimEnd()
|
|
21
|
+
const head = toolName ? `${formatProgressLine(toolName)}\n\n` : ''
|
|
22
|
+
const combined = head + (body || '…')
|
|
23
|
+
if (combined.length <= maxLen) return combined
|
|
24
|
+
return combined.slice(0, maxLen - 1) + '…'
|
|
25
|
+
}
|
|
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
|
+
|
|
35
|
+
export function createEditScheduler(editFn, intervalMs = 1200, firstChunkDelayMs = 250) {
|
|
36
|
+
let pending = null
|
|
37
|
+
let timer = null
|
|
38
|
+
let lastSent = ''
|
|
39
|
+
let inFlight = Promise.resolve()
|
|
40
|
+
let retryDelayMs = 0
|
|
41
|
+
|
|
42
|
+
const flush = () => {
|
|
43
|
+
timer = null
|
|
44
|
+
if (pending === null || pending === lastSent) return
|
|
45
|
+
const text = pending
|
|
46
|
+
inFlight = inFlight.then(async () => {
|
|
47
|
+
try {
|
|
48
|
+
if (retryDelayMs > 0) {
|
|
49
|
+
const delay = retryDelayMs
|
|
50
|
+
retryDelayMs = 0
|
|
51
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
52
|
+
}
|
|
53
|
+
await editFn(text)
|
|
54
|
+
lastSent = text
|
|
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
|
+
}
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
push(text) {
|
|
71
|
+
pending = text
|
|
72
|
+
if (timer) return
|
|
73
|
+
// Fast first chunk: when lastSent is empty, dispatch quickly so user sees immediate response.
|
|
74
|
+
// Subsequent edits throttle to intervalMs to respect Telegram rate limits.
|
|
75
|
+
const delay = !lastSent ? Math.min(firstChunkDelayMs, intervalMs) : Math.max(200, intervalMs)
|
|
76
|
+
timer = setTimeout(flush, delay)
|
|
77
|
+
timer.unref?.()
|
|
78
|
+
},
|
|
79
|
+
async flush() {
|
|
80
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
81
|
+
flush()
|
|
82
|
+
await inFlight
|
|
83
|
+
// If there's still a pending difference (e.g. rate limit delay happened), wait and retry
|
|
84
|
+
if (pending !== null && pending !== lastSent) {
|
|
85
|
+
if (retryDelayMs > 0) {
|
|
86
|
+
await new Promise((r) => setTimeout(r, retryDelayMs + 50))
|
|
87
|
+
retryDelayMs = 0
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await editFn(pending)
|
|
91
|
+
lastSent = pending
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function startTypingHeartbeat(typingFn, intervalMs = 4000) {
|
|
99
|
+
if (typeof typingFn !== 'function') return () => {}
|
|
100
|
+
let active = true
|
|
101
|
+
const safeInvoke = () => {
|
|
102
|
+
if (!active) return
|
|
103
|
+
try {
|
|
104
|
+
typingFn()?.catch?.(() => {})
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
safeInvoke()
|
|
108
|
+
const timer = setInterval(safeInvoke, intervalMs)
|
|
109
|
+
timer.unref?.()
|
|
110
|
+
return () => {
|
|
111
|
+
active = false
|
|
112
|
+
clearInterval(timer)
|
|
113
|
+
}
|
|
114
|
+
}
|
package/lib/telegram-errors.js
CHANGED
|
@@ -39,3 +39,13 @@ export function isTopicGoneError(err) {
|
|
|
39
39
|
const msg = String(err?.message || '').toLowerCase()
|
|
40
40
|
return /thread not found|message thread not found|topic[_ ]?(not found|closed|deleted)|chat (not found|was (upgraded|deleted))|group chat was (upgraded|deleted)/i.test(msg)
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Calculates exponential backoff with max cap for polling retries.
|
|
45
|
+
* errorCount is 1-indexed.
|
|
46
|
+
*/
|
|
47
|
+
export function computePollBackoffMs(baseIntervalMs, errorCount, maxDelayMs = 30_000) {
|
|
48
|
+
const count = Math.max(1, Math.min(10, Number(errorCount) || 1))
|
|
49
|
+
const base = Math.max(100, Number(baseIntervalMs) || 500)
|
|
50
|
+
return Math.min(maxDelayMs, base * Math.pow(2, count - 1))
|
|
51
|
+
}
|