@goodandready/dsh-messenger-gateway 0.3.2 → 0.3.9

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/gateway.js CHANGED
@@ -8,7 +8,8 @@ import { SessionId } from '@deepseek-ai/dsh-session'
8
8
  import createAdapters from './adapters/index.js'
9
9
  import { transcribeVoice, speakText } from './integrations.js'
10
10
  import { attachInboundPhoto, photoOnlyHint } from './photos.js'
11
- import { formatInboundDocument, documentOnlyHint } from './documents.js'
11
+ import { formatInboundDocument, documentOnlyHint, parseDocument } from './documents.js'
12
+ import { listFiles, getFileForDownload, formatFileSize } from './file-manager.js'
12
13
  import { collectAssistantParts, buildOutboundFiles, stripImageUrls } from './outbound.js'
13
14
  import { assistantText, splitText, stripReasoningPreamble, MESSENGER_RELAY_INSTRUCTION } from './text.js'
14
15
  import { chatKey, sessionKey } from './topics.js'
@@ -16,9 +17,16 @@ import { listHomes, resolveNamedHome, upsertHome, normalizeHomeName } from './ho
16
17
  import { createVoicePrefs, shouldSpeakReply } from './voice-prefs.js'
17
18
  import { prepareTtsText, toTelegramVoiceFile } from './tts.js'
18
19
  import {
19
- makeAskToken, buildInlineKeyboard, indexCallbacks, releaseCallbacks,
20
- parseCallbackData, targetMatchesAsk, rejectPendingAsk, REMOVE_KEYBOARD,
20
+ makeAskToken, buildInlineKeyboard, buildMultiSelectKeyboard, indexCallbacks, releaseCallbacks,
21
+ parseCallbackData, parseAskCallback, targetMatchesAsk, rejectPendingAsk, REMOVE_KEYBOARD,
21
22
  } from './ask.js'
23
+ import { processDiagramsAndTables } from './artifacts.js'
24
+ import { createPersonaStore, getPersona, listPersonas, BUILTIN_PERSONAS } from './personas.js'
25
+ import { exportSessionToMarkdown, rewindSession } from './session-ops.js'
26
+ import { formatAlertMessage, resolveAlertTarget } from './alerts.js'
27
+ import { createScheduler, parseRelativeTime, formatRemaining } from './scheduler.js'
28
+ import { listModelCatalog, buildProvidersKeyboard, buildModelsKeyboard, getStoredModelSelection } from './models.js'
29
+ import { buildQuickActionsKeyboard, REMOVE_REPLY_KEYBOARD } from './adapters/telegram.js'
22
30
  import { createPairingStore } from './pairing.js'
23
31
  import {
24
32
  extractTextDelta, extractToolName, buildStreamPreview, formatProgressLine,
@@ -64,6 +72,16 @@ export class Gateway {
64
72
  this.voicePrefs = createVoicePrefs(join(home, 'messenger-gateway', 'voice-prefs.json'))
65
73
  this.chatTts = createVoicePrefs(join(home, 'messenger-gateway', 'chat-tts.json'))
66
74
  this.muted = createVoicePrefs(join(home, 'messenger-gateway', 'muted.json'))
75
+ this.personas = createPersonaStore(join(home, 'messenger-gateway', 'personas.json'))
76
+ this.scheduler = createScheduler(join(home, 'messenger-gateway', 'scheduled.json'), async (task) => {
77
+ const target = {
78
+ platform: task.platform || 'telegram',
79
+ chatId: task.chatId,
80
+ threadId: task.threadId || 0,
81
+ }
82
+ const text = `⏰ <b>[Напоминание]</b>\n${task.text}`
83
+ await this.sendToMessenger(target, { text })
84
+ })
67
85
  this.stats = { sent: 0, errors: 0, startedAt: Date.now() }
68
86
  }
69
87
 
@@ -97,6 +115,20 @@ export class Gateway {
97
115
  return out
98
116
  }
99
117
 
118
+ async sendAlert(type, payload = {}) {
119
+ try {
120
+ const target = resolveAlertTarget(this)
121
+ if (!target) return
122
+ const allowedEvents = this.config.telegram?.alerts?.events || ['error', 'pairing']
123
+ if (type !== 'status' && !allowedEvents.includes(type)) return
124
+
125
+ const text = formatAlertMessage(type, payload)
126
+ await this.sendToMessenger(target, { text })
127
+ } catch (err) {
128
+ this.ctx.logger?.warn?.(`sendAlert (${type}): ${err.message}`)
129
+ }
130
+ }
131
+
100
132
  async start() {
101
133
  this.disposeListener = this.ctx.on('session/event', (session, event) => {
102
134
  const collector = this.pending.get(session.id)
@@ -147,9 +179,11 @@ export class Gateway {
147
179
  this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
148
180
  this.idleTimer.unref?.()
149
181
  }
182
+ this.scheduler.start()
150
183
  }
151
184
 
152
185
  stop() {
186
+ if (this.scheduler) this.scheduler.stop()
153
187
  if (this.disposeListener) this.disposeListener()
154
188
  if (this.idleTimer) clearInterval(this.idleTimer)
155
189
  for (const a of this.adapterList) { try { a.stop() } catch {} }
@@ -195,7 +229,24 @@ export class Gateway {
195
229
  const adapter = this.getAdapter(target.platform)
196
230
  if (!adapter) throw new Error(`adapter ${target.platform} unavailable`)
197
231
  const token = makeAskToken()
198
- const { replyMarkup, callbackKeys } = buildInlineKeyboard(token, payload.buttons || [])
232
+ const isMulti = payload.mode === 'multi' || (Array.isArray(payload.options) && payload.options.length > 0)
233
+ let replyMarkup
234
+ let callbackKeys
235
+ let selectedSet
236
+ let page = 0
237
+ const pageSize = Number(payload.pageSize) || 6
238
+
239
+ if (isMulti) {
240
+ selectedSet = new Set(Array.isArray(payload.selected) ? payload.selected : [])
241
+ const kb = buildMultiSelectKeyboard(token, payload.options || payload.buttons, selectedSet, page, pageSize, payload)
242
+ replyMarkup = kb.replyMarkup
243
+ callbackKeys = kb.callbackKeys
244
+ } else {
245
+ const kb = buildInlineKeyboard(token, payload.buttons || [])
246
+ replyMarkup = kb.replyMarkup
247
+ callbackKeys = kb.callbackKeys
248
+ }
249
+
199
250
  indexCallbacks(this.callbackIndex, callbackKeys, token)
200
251
  try {
201
252
  await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
@@ -215,7 +266,12 @@ export class Gateway {
215
266
  reject(new Error('messenger.ask timed out'))
216
267
  }, timeoutMs)
217
268
  timer.unref?.()
218
- this.pendingAsks.set(token, { resolve, reject, timer, target, callbackKeys })
269
+ this.pendingAsks.set(token, {
270
+ resolve, reject, timer, target, callbackKeys, isMulti,
271
+ options: payload.options || payload.buttons,
272
+ selected: selectedSet,
273
+ page, pageSize, payload,
274
+ })
219
275
  })
220
276
  }
221
277
 
@@ -232,6 +288,7 @@ export class Gateway {
232
288
  try {
233
289
  const { code } = this.pairing.requestCode(userId, { username })
234
290
  await reply(`Нет доступа.\nВаш id: ${userId}\nКод сопряжения: ${code}\n\nВладелец должен отправить боту:\n/pair ${code}`)
291
+ this.sendAlert('pairing', { userId, username, code }).catch(() => {})
235
292
  } catch (err) {
236
293
  if (err.code === 'RATE_LIMIT') await reply('Код уже выдан. Подождите или попросите владельца /pair.')
237
294
  else await reply(`Не удалось выдать код: ${err.message}`)
@@ -248,14 +305,156 @@ export class Gateway {
248
305
  await cb.answer('Кнопка для другого чата')
249
306
  return
250
307
  }
308
+ const action = parseAskCallback(cb.data)
309
+ if (pending.isMulti) {
310
+ if (action.kind === 'toggle') {
311
+ if (pending.selected.has(action.id)) pending.selected.delete(action.id)
312
+ else pending.selected.add(action.id)
313
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
314
+ const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
315
+ pending.callbackKeys = nextKb.callbackKeys
316
+ indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
317
+ try {
318
+ if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
319
+ else await cb.editMessage(cb.message?.text || 'Выбор', nextKb.replyMarkup)
320
+ } catch {}
321
+ await cb.answer(pending.selected.has(action.id) ? 'Выбрано' : 'Снято')
322
+ return
323
+ }
324
+ if (action.kind === 'page') {
325
+ pending.page = action.page
326
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
327
+ const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
328
+ pending.callbackKeys = nextKb.callbackKeys
329
+ indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
330
+ try {
331
+ if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
332
+ else await cb.editMessage(cb.message?.text || 'Выбор', nextKb.replyMarkup)
333
+ } catch {}
334
+ await cb.answer()
335
+ return
336
+ }
337
+ if (action.kind === 'done') {
338
+ clearTimeout(pending.timer)
339
+ this.pendingAsks.delete(askToken)
340
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
341
+ await cb.answer('OK')
342
+ try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
343
+ pending.resolve({ buttonId: 'done', selected: Array.from(pending.selected), data: cb.data })
344
+ return
345
+ }
346
+ if (action.kind === 'cancel') {
347
+ clearTimeout(pending.timer)
348
+ this.pendingAsks.delete(askToken)
349
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
350
+ await cb.answer('Отменено')
351
+ try { await cb.editMessage(cb.message?.text || 'Отменено', REMOVE_KEYBOARD) } catch {}
352
+ pending.resolve({ buttonId: 'cancel', selected: [], data: cb.data })
353
+ return
354
+ }
355
+ }
251
356
  clearTimeout(pending.timer)
252
357
  this.pendingAsks.delete(askToken)
253
358
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
254
359
  await cb.answer('OK')
255
360
  try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
256
- pending.resolve({ buttonId, data: cb.data })
361
+ pending.resolve({ buttonId: action.id || buttonId, data: cb.data })
257
362
  return
258
363
  }
364
+
365
+ // Model picker interactive flow
366
+ if (cb.data?.startsWith('mdl:')) {
367
+ const parts = cb.data.split(':')
368
+ const sub = parts[1]
369
+ // Step 2: Selected provider -> show its models (10 per page)
370
+ if (sub === 'p') {
371
+ const providerId = parts[2]
372
+ const current = this.resolveAgentModel()
373
+ const catalog = await listModelCatalog(this.ctx, current)
374
+ const models = catalog.modelsByProvider.get(providerId) || []
375
+ if (!models.length) {
376
+ await cb.answer('Нет доступных моделей у этого провайдера')
377
+ return
378
+ }
379
+ const kb = buildModelsKeyboard(providerId, models, current.model, 0)
380
+ await cb.answer()
381
+ const text = [
382
+ `🤖 <b>Провайдер:</b> <code>${providerId}</code>`,
383
+ `Выберите модель (страница ${kb.page + 1}/${kb.totalPages}):`,
384
+ ].join('\n')
385
+ try {
386
+ if (cb.editMessage) await cb.editMessage(text, kb)
387
+ } catch {}
388
+ return
389
+ }
390
+ // Pagination for models
391
+ if (sub === 'pg') {
392
+ const providerId = parts[2]
393
+ const page = parseInt(parts[3], 10) || 0
394
+ const current = this.resolveAgentModel()
395
+ const catalog = await listModelCatalog(this.ctx, current)
396
+ const models = catalog.modelsByProvider.get(providerId) || []
397
+ const kb = buildModelsKeyboard(providerId, models, current.model, page)
398
+ await cb.answer()
399
+ const text = [
400
+ `🤖 <b>Провайдер:</b> <code>${providerId}</code>`,
401
+ `Выберите модель (страница ${kb.page + 1}/${kb.totalPages}):`,
402
+ ].join('\n')
403
+ try {
404
+ if (cb.editMessage) await cb.editMessage(text, kb)
405
+ } catch {}
406
+ return
407
+ }
408
+ // Back to providers
409
+ if (sub === 'back') {
410
+ const current = this.resolveAgentModel()
411
+ const catalog = await listModelCatalog(this.ctx, current)
412
+ const kb = buildProvidersKeyboard(catalog.providers, current)
413
+ await cb.answer()
414
+ const text = [
415
+ '🤖 <b>Выберите провайдера:</b>',
416
+ `Текущая: <code>${current.provider}/${current.model}</code>`,
417
+ ].join('\n')
418
+ try {
419
+ if (cb.editMessage) await cb.editMessage(text, kb)
420
+ } catch {}
421
+ return
422
+ }
423
+ // Select model
424
+ if (sub === 's') {
425
+ const key = parts[2]
426
+ const stored = getStoredModelSelection(key)
427
+ if (!stored) {
428
+ await cb.answer('Сессия выбора модели истекла, вызовите /model заново')
429
+ return
430
+ }
431
+ const { provider, model } = stored
432
+ try {
433
+ const adm = this.ctx.get('agentDefaultModel')
434
+ if (adm?.saveSelection) {
435
+ await adm.saveSelection({ provider, model })
436
+ }
437
+ this.config.agent = { ...this.config.agent, provider, model }
438
+ try {
439
+ await this.hooks?.persistAgentModel?.({ provider, model })
440
+ } catch (e) {
441
+ this.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
442
+ }
443
+ await cb.answer(`Выбрана ${model}`)
444
+ try {
445
+ if (cb.editMessage) await cb.editMessage(`✅ Модель успешно переключена на: <b>${provider}/${model}</b>`, REMOVE_KEYBOARD)
446
+ } catch {}
447
+ } catch (err) {
448
+ await cb.answer(`Ошибка: ${err.message}`)
449
+ }
450
+ return
451
+ }
452
+ if (sub === 'cur') {
453
+ await cb.answer()
454
+ return
455
+ }
456
+ }
457
+
259
458
  await cb.answer()
260
459
  }
261
460
 
@@ -300,7 +499,16 @@ export class Gateway {
300
499
  chat.pendingMedia = []
301
500
  }
302
501
  const inboundWasVoice = attachments.some((a) => a.kind === 'voice' || a.kind === 'audio')
303
- const turnInput = { ...input, text: body, attachments, inboundWasVoice }
502
+ let personaOverride
503
+ for (const [pId] of Object.entries(BUILTIN_PERSONAS)) {
504
+ if (pId === 'default') continue
505
+ const tag = `@${pId}`
506
+ if (body.toLowerCase().includes(tag)) {
507
+ personaOverride = pId
508
+ break
509
+ }
510
+ }
511
+ const turnInput = { ...input, text: body, attachments, inboundWasVoice, personaOverride }
304
512
 
305
513
  // Hermes-like steer: while a turn is running, inject followup instead of abort+restart
306
514
  if (this.isChatBusy(chat)) {
@@ -328,6 +536,13 @@ export class Gateway {
328
536
  run.catch((err) => {
329
537
  const msg = err instanceof Error ? err.message : String(err)
330
538
  this.ctx.logger?.warn?.(`dsh-messenger-gateway: background turn: ${msg}`)
539
+ this.sendAlert('error', {
540
+ code: err?.code || 'BACKGROUND_ERROR',
541
+ message: msg,
542
+ sessionId: chat.agent?.session?.id,
543
+ chatId: input.chatId,
544
+ threadId: input.threadId,
545
+ }).catch(() => {})
331
546
  chat.turnActive = false
332
547
  chat.abort = undefined
333
548
  })
@@ -353,12 +568,157 @@ export class Gateway {
353
568
  '/stop — прервать текущий ответ',
354
569
  '/status — статус шлюза',
355
570
  '/model — текущая модель; /model provider model — сменить',
571
+ '/role [name] — персоны и роли агента (/role list)',
572
+ '/skills / /tools — список инструментов и навыков',
573
+ '/fork — ответвление текущей сессии',
574
+ '/export — выгрузка истории диалога в Markdown',
575
+ '/rewind [N] — откат последних N сообщений',
576
+ '/files [dir] — менеджер файлов в рабочей папке агента',
577
+ '/get <path> — скачать файл из рабочей папки в Telegram',
356
578
  '/pair CODE — одобрить пользователя',
357
579
  '/sethome [name] — этот чат = home (имя опционально)',
358
580
  '/home — список home',
581
+ '/setalert — назначить этот чат каналом алертов',
582
+ '/alert [test] — статус канала алертов и тест',
583
+ '/remind <время> <текст> — напоминание (/remind 10m текст, /remind list)',
359
584
  '/voice on|off|status — голосовые ответы',
585
+ '/tts on|off|status — озвучка в этом чате',
586
+ '/mute / /unmute — заглушить уведомления в этот чат',
587
+ '/keyboard on|off — быстрая клавиатура',
588
+ ].join('\n'))
589
+ }
590
+ if (cmd === '/role' || cmd === '/persona') {
591
+ const targetRole = parts[1]?.toLowerCase()
592
+ if (!targetRole || targetRole === 'list') {
593
+ const currentId = this.personas.get(chatId)
594
+ const lines = [
595
+ '🎭 <b>Доступные роли и персоны:</b>',
596
+ '',
597
+ ...listPersonas().map((p) => {
598
+ const isCurrent = p.id === currentId ? ' (активна)' : ''
599
+ return `${p.icon} <b>${p.id}</b> — ${p.name}: ${p.description}${isCurrent}`
600
+ }),
601
+ '',
602
+ 'Смена роли: <code>/role coder</code> (или /role reset)',
603
+ ]
604
+ return reply(lines.join('\n'))
605
+ }
606
+ if (targetRole === 'reset' || targetRole === 'default') {
607
+ this.personas.set(chatId, 'default')
608
+ return reply('🎭 Роль сброшена на стандартную (Default).')
609
+ }
610
+ const persona = getPersona(targetRole)
611
+ if (!persona) {
612
+ return reply(`Неизвестная роль "${targetRole}". Список: /role list`)
613
+ }
614
+ this.personas.set(chatId, persona.id)
615
+ return reply(`🎭 Роль изменена на: ${persona.icon} <b>${persona.name}</b>\n${persona.description}`)
616
+ }
617
+ if (cmd === '/skills' || cmd === '/tools') {
618
+ const toolsList = []
619
+ if (this.ctx.tools?.tools) {
620
+ for (const [name, t] of this.ctx.tools.tools.entries()) {
621
+ toolsList.push(`• <b>${name}</b>: ${t.description || '(нет описания)'}`)
622
+ }
623
+ }
624
+ if (!toolsList.length) {
625
+ return reply('🛠️ <b>Инструменты агента:</b>\n(нет зарегистрированных инструментов)')
626
+ }
627
+ return reply([
628
+ '🛠️ <b>Активные инструменты и скиллы:</b>',
629
+ '',
630
+ ...toolsList,
360
631
  ].join('\n'))
361
632
  }
633
+ if (cmd === '/export') {
634
+ const chat = this.chats.get(key)
635
+ if (!chat?.agent?.session) {
636
+ return reply('Нет активной сессии для экспорта.')
637
+ }
638
+ try {
639
+ const { filename, buffer, messagesCount } = exportSessionToMarkdown(chat.agent.session)
640
+ if (!messagesCount) {
641
+ return reply('Сессия пуста, нет сообщений для экспорта.')
642
+ }
643
+ const file = {
644
+ name: filename,
645
+ mime: 'text/markdown',
646
+ kind: 'document',
647
+ bytes: buffer,
648
+ dataBase64: buffer.toString('base64'),
649
+ }
650
+ return reply({ text: `📄 Экспорт диалога (${messagesCount} сообщений):`, files: [file] })
651
+ } catch (err) {
652
+ return reply(`Ошибка экспорта: ${err.message}`)
653
+ }
654
+ }
655
+ if (cmd === '/rewind') {
656
+ const chat = this.chats.get(key)
657
+ if (!chat?.agent?.session) {
658
+ return reply('Нет активной сессии для отката.')
659
+ }
660
+ const count = Number(parts[1]) || 1
661
+ const res = rewindSession(chat.agent.session, count)
662
+ if (!res.removed) {
663
+ return reply('В истории сессии нет сообщений для отката.')
664
+ }
665
+ try { await this.ctx.sessions?.flush(chat.agent.session) } catch {}
666
+ return reply(`⏪ Откачено сообщений: ${res.removed}. Осталось в контексте: ${res.remaining}.`)
667
+ }
668
+ if (cmd === '/fork') {
669
+ const chat = this.chats.get(key)
670
+ if (!chat?.agent?.session) {
671
+ return reply('Нет активной сессии для форка.')
672
+ }
673
+ try {
674
+ const oldSession = chat.agent.session
675
+ const oldMessages = Array.isArray(oldSession.messages) ? [...oldSession.messages] : []
676
+ const newChat = await this.createChat(key, input)
677
+ if (newChat.agent?.session && oldMessages.length) {
678
+ newChat.agent.session.messages = oldMessages
679
+ try { await this.ctx.sessions?.flush(newChat.agent.session) } catch {}
680
+ }
681
+ this.chats.set(key, newChat)
682
+ return reply(`🔀 Создан форк сессии!\nСтарая сессия: ${oldSession.id}\nНовая активная сессия: ${newChat.agent.session.id}\nКонтекст сохранён (${oldMessages.length} сообщений).`)
683
+ } catch (err) {
684
+ return reply(`Ошибка форка: ${err.message}`)
685
+ }
686
+ }
687
+ if (cmd === '/files') {
688
+ const subPath = parts.slice(1).join(' ').trim() || '.'
689
+ const agentCwd = this.config.agent?.cwd || process.cwd()
690
+ const res = await listFiles(agentCwd, subPath)
691
+ if (!res.ok) return reply(`❌ ${res.error}`)
692
+ return reply(res.formattedText)
693
+ }
694
+ if (cmd === '/get') {
695
+ const targetRel = parts.slice(1).join(' ').trim()
696
+ if (!targetRel) {
697
+ return reply('Укажите имя файла для скачивания: <code>/get <файл></code>\nСписок: <code>/files</code>')
698
+ }
699
+ const agentCwd = this.config.agent?.cwd || process.cwd()
700
+ const maxDocBytes = Number(this.config.media?.maxDocBytes) || 50 * 1024 * 1024
701
+ const res = await getFileForDownload(agentCwd, targetRel, maxDocBytes)
702
+ if (!res.ok) return reply(`❌ ${res.error}`)
703
+ const file = {
704
+ name: res.name,
705
+ mime: res.mime,
706
+ kind: 'document',
707
+ bytes: res.bytes,
708
+ dataBase64: res.bytes.toString('base64'),
709
+ }
710
+ return reply({ text: `📄 Файл: <b>${res.name}</b> (${formatFileSize(res.size)})`, files: [file] })
711
+ }
712
+ if (cmd === '/keyboard') {
713
+ const sub = String(parts[1] || 'status').toLowerCase()
714
+ const tgAdapter = this.getAdapter('telegram')
715
+ if (sub === 'on' || sub === 'off') {
716
+ if (tgAdapter) tgAdapter.quickActions = sub === 'on'
717
+ return reply(sub === 'on' ? 'Быстрая клавиатура: включена.' : 'Быстрая клавиатура: выключена.', sub === 'off' ? { replyMarkup: { remove_keyboard: true } } : undefined)
718
+ }
719
+ const cur = tgAdapter?.quickActions !== false
720
+ return reply(`Быстрая клавиатура: ${cur ? 'on' : 'off'}\nСмена: /keyboard on|off`)
721
+ }
362
722
  if (cmd === '/new') {
363
723
  const chat = this.chats.get(key)
364
724
  if (chat) {
@@ -441,8 +801,18 @@ export class Gateway {
441
801
  }
442
802
  }
443
803
  try {
444
- const sel = this.resolveAgentModel()
445
- return reply(`Текущая модель: ${sel.provider}/${sel.model}\nСмена: /model <provider> <model>`)
804
+ const current = this.resolveAgentModel()
805
+ const catalog = await listModelCatalog(this.ctx, current)
806
+ if (!catalog.providers.length) {
807
+ return reply(`Текущая модель: <code>${current.provider}/${current.model}</code>\nСмена: <code>/model &lt;provider&gt; &lt;model&gt;</code>`)
808
+ }
809
+ const kb = buildProvidersKeyboard(catalog.providers, current)
810
+ return reply([
811
+ '🤖 <b>Выберите провайдера:</b>',
812
+ `Текущая модель: <code>${current.provider}/${current.model}</code>`,
813
+ ].join('\n'), {
814
+ replyMarkup: kb,
815
+ })
446
816
  } catch (e) {
447
817
  return reply(e.message)
448
818
  }
@@ -477,8 +847,101 @@ export class Gateway {
477
847
  if (!homes.length) return reply('Home не задан. /sethome или /sethome name')
478
848
  return reply(['Homes:', ...homes.map((h) => `• ${h.name}: chat ${h.chatId}${h.threadId ? ` topic ${h.threadId}` : ''}`)].join('\n'))
479
849
  }
850
+ if (cmd === '/setalert') {
851
+ if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
852
+ const nextTg = {
853
+ ...this.tg(),
854
+ alerts: {
855
+ ...(this.tg().alerts || {}),
856
+ enabled: true,
857
+ chatId,
858
+ threadId: threadId || 0,
859
+ },
860
+ }
861
+ this.config.telegram = nextTg
862
+ try { await this.hooks?.persistHomes?.(nextTg) } catch {}
863
+ return reply(`🔔 Этот чат назначен каналом алертов (chat: ${chatId}${threadId ? `, topic: ${threadId}` : ''}).`)
864
+ }
865
+ if (cmd === '/alert') {
866
+ const sub = parts[1]?.toLowerCase()
867
+ if (sub === 'test') {
868
+ const target = resolveAlertTarget(this)
869
+ if (!target) return reply('Канал алертов не настроен или выключен. Назначить: /setalert')
870
+ await this.sendAlert('status', { title: 'Тестовый алерт', details: `Отправлен пользователем id ${userId}` })
871
+ return reply('Тестовый алерт отправлен в канал алертов.')
872
+ }
873
+ const target = resolveAlertTarget(this)
874
+ const alertsCfg = this.tg().alerts || {}
875
+ return reply([
876
+ '🔔 <b>Канал алертов:</b>',
877
+ `Статус: ${alertsCfg.enabled ? 'включен' : 'выключен'}`,
878
+ `Чат: ${target ? `${target.chatId}${target.threadId ? ` (topic: ${target.threadId})` : ''}` : '(не назначен)'}`,
879
+ `События: ${(alertsCfg.events || ['error', 'pairing']).join(', ')}`,
880
+ '',
881
+ 'Команды:',
882
+ '/setalert — назначить текущий чат каналом алертов',
883
+ '/alert test — отправить тестовый алерт',
884
+ ].join('\n'))
885
+ }
886
+ if (cmd === '/remind') {
887
+ const sub = parts[1]?.toLowerCase()
888
+ if (sub === 'list') {
889
+ const active = await this.scheduler.list(chatId)
890
+ if (!active.length) return reply('Нет активных напоминаний для этого чата.')
891
+ const lines = [
892
+ '⏰ <b>Активные напоминания:</b>',
893
+ '',
894
+ ...active.map((t) => {
895
+ const left = formatRemaining(t.dueAt - Date.now())
896
+ return `• <code>${t.id}</code> (через ${left}): ${t.text}`
897
+ }),
898
+ '',
899
+ 'Отмена: <code>/remind cancel ID</code>',
900
+ ]
901
+ return reply(lines.join('\n'))
902
+ }
903
+ if (sub === 'cancel') {
904
+ const targetId = parts[2]?.trim()
905
+ if (!targetId) return reply('Укажите ID напоминания: <code>/remind cancel ID</code>')
906
+ const ok = await this.scheduler.cancel(targetId, chatId)
907
+ return reply(ok ? `✅ Напоминание <code>${targetId}</code> отменено.` : `❌ Напоминание с ID <code>${targetId}</code> не найдено.`)
908
+ }
909
+ const timeArg = parts[1]
910
+ const textArg = parts.slice(2).join(' ').trim()
911
+ const delayMs = parseRelativeTime(timeArg)
912
+ if (!delayMs || !textArg) {
913
+ return reply([
914
+ '⏰ <b>Напоминания:</b>',
915
+ 'Создать: <code>/remind &lt;время&gt; &lt;текст&gt;</code>',
916
+ 'Примеры: <code>/remind 10m Позвонить</code>, <code>/remind 2h Проверить деплой</code>',
917
+ 'Список: <code>/remind list</code>',
918
+ 'Отмена: <code>/remind cancel ID</code>',
919
+ ].join('\n'))
920
+ }
921
+ const dueAt = Date.now() + delayMs
922
+ const task = await this.scheduler.schedule({
923
+ platform: platform || 'telegram',
924
+ chatId,
925
+ threadId: threadId || 0,
926
+ userId,
927
+ text: textArg,
928
+ dueAt,
929
+ })
930
+ const left = formatRemaining(delayMs)
931
+ return reply(`⏰ Напоминание установлено на <b>через ${left}</b> (ID: <code>${task.id}</code>):\n«${textArg}»`)
932
+ }
480
933
  if (cmd === '/voice') {
481
934
  const sub = String(parts[1] || 'status').toLowerCase()
935
+ if (sub === 'summary') {
936
+ const val = parts[2]?.toLowerCase()
937
+ if (val === 'on' || val === 'off') {
938
+ if (!this.config.tts) this.config.tts = {}
939
+ this.config.tts.voiceSummary = val === 'on'
940
+ return reply(`Голосовое саммари (TL;DR): ${val === 'on' ? 'включено' : 'выключено'}`)
941
+ }
942
+ const state = this.config.tts?.voiceSummary ? 'on' : 'off'
943
+ return reply(`Голосовое саммари (TL;DR): ${state}\nПереключение: <code>/voice summary on|off</code>`)
944
+ }
482
945
  if (sub === 'on' || sub === 'off') {
483
946
  this.voicePrefs.set(userId, sub === 'on')
484
947
  return reply(sub === 'on' ? 'Голосовые ответы: on (для вас)' : 'Голосовые ответы: off (для вас)')
@@ -486,7 +949,84 @@ export class Gateway {
486
949
  const pref = this.voicePrefs.get(userId)
487
950
  const mode = this.tg().voiceMode || 'mirror'
488
951
  const prefLine = pref === null ? 'не задан (/voice on|off)' : (pref ? 'on' : 'off')
489
- return reply(`voiceMode=${mode}\nваш /voice: ${prefLine}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}`)
952
+ const summaryState = this.config.tts?.voiceSummary ? 'on' : 'off'
953
+ return reply(`voiceMode=${mode}\nваш /voice: ${prefLine}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}\nvoice summary: ${summaryState}`)
954
+ }
955
+ if (cmd === '/topic') {
956
+ const topicName = parts.slice(1).join(' ').trim()
957
+ if (!topicName) {
958
+ return reply('Использование: <code>/topic &lt;название&gt;</code>\nСоздает новый топик в супергруппе и изолированную сессию под задачу.')
959
+ }
960
+ const tgAdapter = this.getAdapter('telegram')
961
+ if (!tgAdapter?.createForumTopic) {
962
+ return reply('Создание топиков доступно только в Telegram.')
963
+ }
964
+ try {
965
+ const res = await tgAdapter.createForumTopic(chatId, topicName)
966
+ const newThreadId = res?.message_thread_id
967
+ await reply(`🎯 Создан новый топик <b>«${topicName}»</b> (ID: <code>${newThreadId}</code>).\nПерейдите в созданный топик для работы над задачей!`)
968
+ if (newThreadId) {
969
+ await tgAdapter.sendTo(chatId, {
970
+ text: `👋 Привет! Это изолированная сессия для задачи <b>«${topicName}»</b>.\nЧем могу помочь?`,
971
+ }, { threadId: newThreadId })
972
+ }
973
+ return
974
+ } catch (err) {
975
+ return reply(`Не удалось создать топик: ${err.message}\n(Убедитесь, что бот является администратором группы и включены темы/форумы)`)
976
+ }
977
+ }
978
+ if (cmd === '/top') {
979
+ const mem = process.memoryUsage()
980
+ const rssMb = (mem.rss / 1024 / 1024).toFixed(1)
981
+ const heapMb = (mem.heapUsed / 1024 / 1024).toFixed(1)
982
+ const sec = Math.floor((Date.now() - this.stats.startedAt) / 1000)
983
+ const hh = String(Math.floor(sec / 3600)).padStart(2, '0')
984
+ const mm = String(Math.floor((sec % 3600) / 60)).padStart(2, '0')
985
+ const ss = String(sec % 60).padStart(2, '0')
986
+ let activeReminders = 0
987
+ try { activeReminders = (await this.scheduler.list()).length } catch {}
988
+ let currentModel = 'не задана'
989
+ try {
990
+ const m = this.resolveAgentModel()
991
+ currentModel = `${m.provider}/${m.model}`
992
+ } catch {}
993
+
994
+ return reply([
995
+ '📊 <b>DSH System & Resources:</b>',
996
+ `• <b>Память (RSS):</b> ${rssMb} MB`,
997
+ `• <b>Heap:</b> ${heapMb} MB`,
998
+ `• <b>Аптайм:</b> ${hh}:${mm}:${ss}`,
999
+ `• <b>Активных чатов:</b> ${this.chats.size}`,
1000
+ `• <b>Напоминаний в очереди:</b> ${activeReminders}`,
1001
+ `• <b>Активная модель:</b> <code>${currentModel}</code>`,
1002
+ `• <b>Сообщений отправлено:</b> ${this.stats.sent}`,
1003
+ `• <b>Ошибок:</b> ${this.stats.errors}`,
1004
+ ].join('\n'))
1005
+ }
1006
+ if (cmd === '/keyboard') {
1007
+ const sub = String(parts[1] || '').toLowerCase()
1008
+ const tgAdapter = this.getAdapter('telegram')
1009
+ if (sub === 'on') {
1010
+ if (tgAdapter) tgAdapter.quickActions = true
1011
+ return reply('Клавиатура быстрых действий включена.', {
1012
+ replyMarkup: buildQuickActionsKeyboard(),
1013
+ })
1014
+ }
1015
+ if (sub === 'off') {
1016
+ if (tgAdapter) tgAdapter.quickActions = false
1017
+ return reply('Клавиатура быстрых действий выключена.', {
1018
+ replyMarkup: REMOVE_REPLY_KEYBOARD,
1019
+ })
1020
+ }
1021
+ const curState = tgAdapter?.quickActions ? 'включена' : 'выключена'
1022
+ return reply([
1023
+ '⌨️ <b>Клавиатура быстрых действий:</b>',
1024
+ `Текущее состояние: <b>${curState}</b>`,
1025
+ '',
1026
+ 'Команды:',
1027
+ '<code>/keyboard on</code> — показать кнопки',
1028
+ '<code>/keyboard off</code> — скрыть кнопки',
1029
+ ].join('\n'))
490
1030
  }
491
1031
  if (cmd === '/tts') {
492
1032
  const sub = String(parts[1] || 'status').toLowerCase()
@@ -574,9 +1114,14 @@ export class Gateway {
574
1114
  }
575
1115
 
576
1116
  async buildUserContent(input, signal) {
577
- const { text, attachments = [], replyText, steer } = input
1117
+ const { text, attachments = [], replyText, steer, personaOverride } = input
578
1118
  const parts = []
579
1119
  parts.push(String(this.config.agent?.instructionPrefix || MESSENGER_RELAY_INSTRUCTION))
1120
+ const activePersonaId = personaOverride || this.personas.get(input.chatId)
1121
+ const activePersona = getPersona(activePersonaId)
1122
+ if (activePersona?.instruction) {
1123
+ parts.push(`[Persona: ${activePersona.name} (${activePersona.icon})]\n${activePersona.instruction}`)
1124
+ }
580
1125
  if (steer) parts.push('[Steer / дополнение к текущему ходу — учти вместе с предыдущим запросом, не начинай ответ заново с нуля]')
581
1126
  if (replyText?.trim()) parts.push(`[Ответ на сообщение: ${replyText.trim()}]`)
582
1127
  const blocks = []
@@ -606,7 +1151,14 @@ export class Gateway {
606
1151
  parts.push(`[Голосовое сообщение (dsh-voice недоступен: ${msg})]`)
607
1152
  }
608
1153
  } else if (att.kind === 'document' || att.kind === 'video' || att.kind === 'animation' || att.kind === 'sticker') {
609
- parts.push(formatInboundDocument(att))
1154
+ let parsed = null
1155
+ if (att.kind === 'document' && att.path) {
1156
+ try {
1157
+ const maxDocBytes = Number(this.config.media?.maxTextInjectBytes) || 100 * 1024
1158
+ parsed = await parseDocument(att.path, { maxBytes: maxDocBytes })
1159
+ } catch {}
1160
+ }
1161
+ parts.push(formatInboundDocument(att, parsed))
610
1162
  } else {
611
1163
  parts.push(`[Файл: ${att.path}${att.name ? ` (${att.name})` : ''}]`)
612
1164
  }
@@ -688,16 +1240,28 @@ export class Gateway {
688
1240
  if (progress) try { await progress.remove() } catch {}
689
1241
  progress = null
690
1242
  if (typeof react === 'function') react('').catch?.(() => {})
691
- const answer = stripReasoningPreamble(stripImageUrls(collector.lastText || collector.streamText || collector.parts.join('\n\n')))
1243
+ const rawAnswer = stripReasoningPreamble(stripImageUrls(collector.lastText || collector.streamText || collector.parts.join('\n\n')))
1244
+ const processed = processDiagramsAndTables(rawAnswer, {
1245
+ artifactPreviews: this.tg().artifactPreviews !== false,
1246
+ })
1247
+ const answer = processed.text
692
1248
  if (collector.reason?.kind === 'error') {
693
1249
  const err = collector.reason.error
694
1250
  const msg = `Ошибка агента: ${err?.code || 'error'}: ${err?.message || 'unknown'}`
1251
+ this.sendAlert('error', {
1252
+ code: err?.code || 'AGENT_ERROR',
1253
+ message: err?.message || 'unknown',
1254
+ sessionId,
1255
+ chatId: input.chatId,
1256
+ threadId: input.threadId,
1257
+ }).catch(() => {})
695
1258
  if (stream) { await scheduler?.flush(); await stream.finalize(msg) }
696
1259
  else await reply(msg)
697
1260
  return
698
1261
  }
699
1262
  const files = await buildOutboundFiles(this.ctx, this.baseUrl(), collector, { signal, logger: this.ctx.logger })
700
- if (!answer && !files.length) {
1263
+ const allFiles = [...files, ...(processed.files || [])]
1264
+ if (!answer && !allFiles.length) {
701
1265
  if (stream) { await scheduler?.flush(); await stream.finalize('(нет ответа)') }
702
1266
  else await reply('(нет ответа)')
703
1267
  return
@@ -708,10 +1272,10 @@ export class Gateway {
708
1272
  await scheduler?.flush()
709
1273
  await stream.finalize(chunks[0] || '(нет ответа)')
710
1274
  for (let i = 1; i < chunks.length; i++) await reply({ text: chunks[i] })
711
- if (files.length) await reply({ files })
1275
+ if (allFiles.length) await reply({ files: allFiles })
712
1276
  } else {
713
1277
  for (let i = 0; i < chunks.length; i++) {
714
- await reply({ text: chunks[i] || undefined, files: i === 0 ? files : [] })
1278
+ await reply({ text: chunks[i] || undefined, files: i === 0 ? allFiles : [] })
715
1279
  }
716
1280
  }
717
1281
  const chatTtsPref = this.chatTts.get(chat.target?.chatId)
@@ -723,7 +1287,8 @@ export class Gateway {
723
1287
  chatPref: chatTtsPref,
724
1288
  })
725
1289
  if (speak && !signal.aborted) {
726
- const ttsText = prepareTtsText(answer, this.config.tts?.maxChars)
1290
+ const isVoiceSummary = this.config.tts?.voiceSummary === true
1291
+ const ttsText = prepareTtsText(answer, this.config.tts?.maxChars, { voiceSummary: isVoiceSummary })
727
1292
  if (ttsText) {
728
1293
  try {
729
1294
  const spoken = await speakText(this.baseUrl(), ttsText, signal)
@@ -736,6 +1301,13 @@ export class Gateway {
736
1301
  }
737
1302
  } catch (err) {
738
1303
  if (!signal?.aborted) {
1304
+ this.sendAlert('error', {
1305
+ code: err?.code || 'EXCEPTION',
1306
+ message: err?.message || String(err),
1307
+ sessionId,
1308
+ chatId: input.chatId,
1309
+ threadId: input.threadId,
1310
+ }).catch(() => {})
739
1311
  try {
740
1312
  if (stream) await stream.finalize(`Сбой: ${err.message}`)
741
1313
  else await reply(`Сбой: ${err.message}`)