@goodandready/dsh-messenger-gateway 0.3.2 → 0.3.8

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,14 @@ 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'
22
28
  import { createPairingStore } from './pairing.js'
23
29
  import {
24
30
  extractTextDelta, extractToolName, buildStreamPreview, formatProgressLine,
@@ -64,6 +70,16 @@ export class Gateway {
64
70
  this.voicePrefs = createVoicePrefs(join(home, 'messenger-gateway', 'voice-prefs.json'))
65
71
  this.chatTts = createVoicePrefs(join(home, 'messenger-gateway', 'chat-tts.json'))
66
72
  this.muted = createVoicePrefs(join(home, 'messenger-gateway', 'muted.json'))
73
+ this.personas = createPersonaStore(join(home, 'messenger-gateway', 'personas.json'))
74
+ this.scheduler = createScheduler(join(home, 'messenger-gateway', 'scheduled.json'), async (task) => {
75
+ const target = {
76
+ platform: task.platform || 'telegram',
77
+ chatId: task.chatId,
78
+ threadId: task.threadId || 0,
79
+ }
80
+ const text = `⏰ <b>[Напоминание]</b>\n${task.text}`
81
+ await this.sendToMessenger(target, { text })
82
+ })
67
83
  this.stats = { sent: 0, errors: 0, startedAt: Date.now() }
68
84
  }
69
85
 
@@ -97,6 +113,20 @@ export class Gateway {
97
113
  return out
98
114
  }
99
115
 
116
+ async sendAlert(type, payload = {}) {
117
+ try {
118
+ const target = resolveAlertTarget(this)
119
+ if (!target) return
120
+ const allowedEvents = this.config.telegram?.alerts?.events || ['error', 'pairing']
121
+ if (type !== 'status' && !allowedEvents.includes(type)) return
122
+
123
+ const text = formatAlertMessage(type, payload)
124
+ await this.sendToMessenger(target, { text })
125
+ } catch (err) {
126
+ this.ctx.logger?.warn?.(`sendAlert (${type}): ${err.message}`)
127
+ }
128
+ }
129
+
100
130
  async start() {
101
131
  this.disposeListener = this.ctx.on('session/event', (session, event) => {
102
132
  const collector = this.pending.get(session.id)
@@ -147,9 +177,11 @@ export class Gateway {
147
177
  this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
148
178
  this.idleTimer.unref?.()
149
179
  }
180
+ this.scheduler.start()
150
181
  }
151
182
 
152
183
  stop() {
184
+ if (this.scheduler) this.scheduler.stop()
153
185
  if (this.disposeListener) this.disposeListener()
154
186
  if (this.idleTimer) clearInterval(this.idleTimer)
155
187
  for (const a of this.adapterList) { try { a.stop() } catch {} }
@@ -195,7 +227,24 @@ export class Gateway {
195
227
  const adapter = this.getAdapter(target.platform)
196
228
  if (!adapter) throw new Error(`adapter ${target.platform} unavailable`)
197
229
  const token = makeAskToken()
198
- const { replyMarkup, callbackKeys } = buildInlineKeyboard(token, payload.buttons || [])
230
+ const isMulti = payload.mode === 'multi' || (Array.isArray(payload.options) && payload.options.length > 0)
231
+ let replyMarkup
232
+ let callbackKeys
233
+ let selectedSet
234
+ let page = 0
235
+ const pageSize = Number(payload.pageSize) || 6
236
+
237
+ if (isMulti) {
238
+ selectedSet = new Set(Array.isArray(payload.selected) ? payload.selected : [])
239
+ const kb = buildMultiSelectKeyboard(token, payload.options || payload.buttons, selectedSet, page, pageSize, payload)
240
+ replyMarkup = kb.replyMarkup
241
+ callbackKeys = kb.callbackKeys
242
+ } else {
243
+ const kb = buildInlineKeyboard(token, payload.buttons || [])
244
+ replyMarkup = kb.replyMarkup
245
+ callbackKeys = kb.callbackKeys
246
+ }
247
+
199
248
  indexCallbacks(this.callbackIndex, callbackKeys, token)
200
249
  try {
201
250
  await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
@@ -215,7 +264,12 @@ export class Gateway {
215
264
  reject(new Error('messenger.ask timed out'))
216
265
  }, timeoutMs)
217
266
  timer.unref?.()
218
- this.pendingAsks.set(token, { resolve, reject, timer, target, callbackKeys })
267
+ this.pendingAsks.set(token, {
268
+ resolve, reject, timer, target, callbackKeys, isMulti,
269
+ options: payload.options || payload.buttons,
270
+ selected: selectedSet,
271
+ page, pageSize, payload,
272
+ })
219
273
  })
220
274
  }
221
275
 
@@ -232,6 +286,7 @@ export class Gateway {
232
286
  try {
233
287
  const { code } = this.pairing.requestCode(userId, { username })
234
288
  await reply(`Нет доступа.\nВаш id: ${userId}\nКод сопряжения: ${code}\n\nВладелец должен отправить боту:\n/pair ${code}`)
289
+ this.sendAlert('pairing', { userId, username, code }).catch(() => {})
235
290
  } catch (err) {
236
291
  if (err.code === 'RATE_LIMIT') await reply('Код уже выдан. Подождите или попросите владельца /pair.')
237
292
  else await reply(`Не удалось выдать код: ${err.message}`)
@@ -248,12 +303,60 @@ export class Gateway {
248
303
  await cb.answer('Кнопка для другого чата')
249
304
  return
250
305
  }
306
+ const action = parseAskCallback(cb.data)
307
+ if (pending.isMulti) {
308
+ if (action.kind === 'toggle') {
309
+ if (pending.selected.has(action.id)) pending.selected.delete(action.id)
310
+ else pending.selected.add(action.id)
311
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
312
+ const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
313
+ pending.callbackKeys = nextKb.callbackKeys
314
+ indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
315
+ try {
316
+ if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
317
+ else await cb.editMessage(cb.message?.text || 'Выбор', nextKb.replyMarkup)
318
+ } catch {}
319
+ await cb.answer(pending.selected.has(action.id) ? 'Выбрано' : 'Снято')
320
+ return
321
+ }
322
+ if (action.kind === 'page') {
323
+ pending.page = action.page
324
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
325
+ const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
326
+ pending.callbackKeys = nextKb.callbackKeys
327
+ indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
328
+ try {
329
+ if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
330
+ else await cb.editMessage(cb.message?.text || 'Выбор', nextKb.replyMarkup)
331
+ } catch {}
332
+ await cb.answer()
333
+ return
334
+ }
335
+ if (action.kind === 'done') {
336
+ clearTimeout(pending.timer)
337
+ this.pendingAsks.delete(askToken)
338
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
339
+ await cb.answer('OK')
340
+ try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
341
+ pending.resolve({ buttonId: 'done', selected: Array.from(pending.selected), data: cb.data })
342
+ return
343
+ }
344
+ if (action.kind === 'cancel') {
345
+ clearTimeout(pending.timer)
346
+ this.pendingAsks.delete(askToken)
347
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
348
+ await cb.answer('Отменено')
349
+ try { await cb.editMessage(cb.message?.text || 'Отменено', REMOVE_KEYBOARD) } catch {}
350
+ pending.resolve({ buttonId: 'cancel', selected: [], data: cb.data })
351
+ return
352
+ }
353
+ }
251
354
  clearTimeout(pending.timer)
252
355
  this.pendingAsks.delete(askToken)
253
356
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
254
357
  await cb.answer('OK')
255
358
  try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
256
- pending.resolve({ buttonId, data: cb.data })
359
+ pending.resolve({ buttonId: action.id || buttonId, data: cb.data })
257
360
  return
258
361
  }
259
362
  await cb.answer()
@@ -300,7 +403,16 @@ export class Gateway {
300
403
  chat.pendingMedia = []
301
404
  }
302
405
  const inboundWasVoice = attachments.some((a) => a.kind === 'voice' || a.kind === 'audio')
303
- const turnInput = { ...input, text: body, attachments, inboundWasVoice }
406
+ let personaOverride
407
+ for (const [pId] of Object.entries(BUILTIN_PERSONAS)) {
408
+ if (pId === 'default') continue
409
+ const tag = `@${pId}`
410
+ if (body.toLowerCase().includes(tag)) {
411
+ personaOverride = pId
412
+ break
413
+ }
414
+ }
415
+ const turnInput = { ...input, text: body, attachments, inboundWasVoice, personaOverride }
304
416
 
305
417
  // Hermes-like steer: while a turn is running, inject followup instead of abort+restart
306
418
  if (this.isChatBusy(chat)) {
@@ -328,6 +440,13 @@ export class Gateway {
328
440
  run.catch((err) => {
329
441
  const msg = err instanceof Error ? err.message : String(err)
330
442
  this.ctx.logger?.warn?.(`dsh-messenger-gateway: background turn: ${msg}`)
443
+ this.sendAlert('error', {
444
+ code: err?.code || 'BACKGROUND_ERROR',
445
+ message: msg,
446
+ sessionId: chat.agent?.session?.id,
447
+ chatId: input.chatId,
448
+ threadId: input.threadId,
449
+ }).catch(() => {})
331
450
  chat.turnActive = false
332
451
  chat.abort = undefined
333
452
  })
@@ -353,12 +472,157 @@ export class Gateway {
353
472
  '/stop — прервать текущий ответ',
354
473
  '/status — статус шлюза',
355
474
  '/model — текущая модель; /model provider model — сменить',
475
+ '/role [name] — персоны и роли агента (/role list)',
476
+ '/skills / /tools — список инструментов и навыков',
477
+ '/fork — ответвление текущей сессии',
478
+ '/export — выгрузка истории диалога в Markdown',
479
+ '/rewind [N] — откат последних N сообщений',
480
+ '/files [dir] — менеджер файлов в рабочей папке агента',
481
+ '/get <path> — скачать файл из рабочей папки в Telegram',
356
482
  '/pair CODE — одобрить пользователя',
357
483
  '/sethome [name] — этот чат = home (имя опционально)',
358
484
  '/home — список home',
485
+ '/setalert — назначить этот чат каналом алертов',
486
+ '/alert [test] — статус канала алертов и тест',
487
+ '/remind <время> <текст> — напоминание (/remind 10m текст, /remind list)',
359
488
  '/voice on|off|status — голосовые ответы',
489
+ '/tts on|off|status — озвучка в этом чате',
490
+ '/mute / /unmute — заглушить уведомления в этот чат',
491
+ '/keyboard on|off — быстрая клавиатура',
360
492
  ].join('\n'))
361
493
  }
494
+ if (cmd === '/role' || cmd === '/persona') {
495
+ const targetRole = parts[1]?.toLowerCase()
496
+ if (!targetRole || targetRole === 'list') {
497
+ const currentId = this.personas.get(chatId)
498
+ const lines = [
499
+ '🎭 <b>Доступные роли и персоны:</b>',
500
+ '',
501
+ ...listPersonas().map((p) => {
502
+ const isCurrent = p.id === currentId ? ' (активна)' : ''
503
+ return `${p.icon} <b>${p.id}</b> — ${p.name}: ${p.description}${isCurrent}`
504
+ }),
505
+ '',
506
+ 'Смена роли: <code>/role coder</code> (или /role reset)',
507
+ ]
508
+ return reply(lines.join('\n'))
509
+ }
510
+ if (targetRole === 'reset' || targetRole === 'default') {
511
+ this.personas.set(chatId, 'default')
512
+ return reply('🎭 Роль сброшена на стандартную (Default).')
513
+ }
514
+ const persona = getPersona(targetRole)
515
+ if (!persona) {
516
+ return reply(`Неизвестная роль "${targetRole}". Список: /role list`)
517
+ }
518
+ this.personas.set(chatId, persona.id)
519
+ return reply(`🎭 Роль изменена на: ${persona.icon} <b>${persona.name}</b>\n${persona.description}`)
520
+ }
521
+ if (cmd === '/skills' || cmd === '/tools') {
522
+ const toolsList = []
523
+ if (this.ctx.tools?.tools) {
524
+ for (const [name, t] of this.ctx.tools.tools.entries()) {
525
+ toolsList.push(`• <b>${name}</b>: ${t.description || '(нет описания)'}`)
526
+ }
527
+ }
528
+ if (!toolsList.length) {
529
+ return reply('🛠️ <b>Инструменты агента:</b>\n(нет зарегистрированных инструментов)')
530
+ }
531
+ return reply([
532
+ '🛠️ <b>Активные инструменты и скиллы:</b>',
533
+ '',
534
+ ...toolsList,
535
+ ].join('\n'))
536
+ }
537
+ if (cmd === '/export') {
538
+ const chat = this.chats.get(key)
539
+ if (!chat?.agent?.session) {
540
+ return reply('Нет активной сессии для экспорта.')
541
+ }
542
+ try {
543
+ const { filename, buffer, messagesCount } = exportSessionToMarkdown(chat.agent.session)
544
+ if (!messagesCount) {
545
+ return reply('Сессия пуста, нет сообщений для экспорта.')
546
+ }
547
+ const file = {
548
+ name: filename,
549
+ mime: 'text/markdown',
550
+ kind: 'document',
551
+ bytes: buffer,
552
+ dataBase64: buffer.toString('base64'),
553
+ }
554
+ return reply({ text: `📄 Экспорт диалога (${messagesCount} сообщений):`, files: [file] })
555
+ } catch (err) {
556
+ return reply(`Ошибка экспорта: ${err.message}`)
557
+ }
558
+ }
559
+ if (cmd === '/rewind') {
560
+ const chat = this.chats.get(key)
561
+ if (!chat?.agent?.session) {
562
+ return reply('Нет активной сессии для отката.')
563
+ }
564
+ const count = Number(parts[1]) || 1
565
+ const res = rewindSession(chat.agent.session, count)
566
+ if (!res.removed) {
567
+ return reply('В истории сессии нет сообщений для отката.')
568
+ }
569
+ try { await this.ctx.sessions?.flush(chat.agent.session) } catch {}
570
+ return reply(`⏪ Откачено сообщений: ${res.removed}. Осталось в контексте: ${res.remaining}.`)
571
+ }
572
+ if (cmd === '/fork') {
573
+ const chat = this.chats.get(key)
574
+ if (!chat?.agent?.session) {
575
+ return reply('Нет активной сессии для форка.')
576
+ }
577
+ try {
578
+ const oldSession = chat.agent.session
579
+ const oldMessages = Array.isArray(oldSession.messages) ? [...oldSession.messages] : []
580
+ const newChat = await this.createChat(key, input)
581
+ if (newChat.agent?.session && oldMessages.length) {
582
+ newChat.agent.session.messages = oldMessages
583
+ try { await this.ctx.sessions?.flush(newChat.agent.session) } catch {}
584
+ }
585
+ this.chats.set(key, newChat)
586
+ return reply(`🔀 Создан форк сессии!\nСтарая сессия: ${oldSession.id}\nНовая активная сессия: ${newChat.agent.session.id}\nКонтекст сохранён (${oldMessages.length} сообщений).`)
587
+ } catch (err) {
588
+ return reply(`Ошибка форка: ${err.message}`)
589
+ }
590
+ }
591
+ if (cmd === '/files') {
592
+ const subPath = parts.slice(1).join(' ').trim() || '.'
593
+ const agentCwd = this.config.agent?.cwd || process.cwd()
594
+ const res = await listFiles(agentCwd, subPath)
595
+ if (!res.ok) return reply(`❌ ${res.error}`)
596
+ return reply(res.formattedText)
597
+ }
598
+ if (cmd === '/get') {
599
+ const targetRel = parts.slice(1).join(' ').trim()
600
+ if (!targetRel) {
601
+ return reply('Укажите имя файла для скачивания: <code>/get <файл></code>\nСписок: <code>/files</code>')
602
+ }
603
+ const agentCwd = this.config.agent?.cwd || process.cwd()
604
+ const maxDocBytes = Number(this.config.media?.maxDocBytes) || 50 * 1024 * 1024
605
+ const res = await getFileForDownload(agentCwd, targetRel, maxDocBytes)
606
+ if (!res.ok) return reply(`❌ ${res.error}`)
607
+ const file = {
608
+ name: res.name,
609
+ mime: res.mime,
610
+ kind: 'document',
611
+ bytes: res.bytes,
612
+ dataBase64: res.bytes.toString('base64'),
613
+ }
614
+ return reply({ text: `📄 Файл: <b>${res.name}</b> (${formatFileSize(res.size)})`, files: [file] })
615
+ }
616
+ if (cmd === '/keyboard') {
617
+ const sub = String(parts[1] || 'status').toLowerCase()
618
+ const tgAdapter = this.getAdapter('telegram')
619
+ if (sub === 'on' || sub === 'off') {
620
+ if (tgAdapter) tgAdapter.quickActions = sub === 'on'
621
+ return reply(sub === 'on' ? 'Быстрая клавиатура: включена.' : 'Быстрая клавиатура: выключена.', sub === 'off' ? { replyMarkup: { remove_keyboard: true } } : undefined)
622
+ }
623
+ const cur = tgAdapter?.quickActions !== false
624
+ return reply(`Быстрая клавиатура: ${cur ? 'on' : 'off'}\nСмена: /keyboard on|off`)
625
+ }
362
626
  if (cmd === '/new') {
363
627
  const chat = this.chats.get(key)
364
628
  if (chat) {
@@ -477,6 +741,89 @@ export class Gateway {
477
741
  if (!homes.length) return reply('Home не задан. /sethome или /sethome name')
478
742
  return reply(['Homes:', ...homes.map((h) => `• ${h.name}: chat ${h.chatId}${h.threadId ? ` topic ${h.threadId}` : ''}`)].join('\n'))
479
743
  }
744
+ if (cmd === '/setalert') {
745
+ if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
746
+ const nextTg = {
747
+ ...this.tg(),
748
+ alerts: {
749
+ ...(this.tg().alerts || {}),
750
+ enabled: true,
751
+ chatId,
752
+ threadId: threadId || 0,
753
+ },
754
+ }
755
+ this.config.telegram = nextTg
756
+ try { await this.hooks?.persistHomes?.(nextTg) } catch {}
757
+ return reply(`🔔 Этот чат назначен каналом алертов (chat: ${chatId}${threadId ? `, topic: ${threadId}` : ''}).`)
758
+ }
759
+ if (cmd === '/alert') {
760
+ const sub = parts[1]?.toLowerCase()
761
+ if (sub === 'test') {
762
+ const target = resolveAlertTarget(this)
763
+ if (!target) return reply('Канал алертов не настроен или выключен. Назначить: /setalert')
764
+ await this.sendAlert('status', { title: 'Тестовый алерт', details: `Отправлен пользователем id ${userId}` })
765
+ return reply('Тестовый алерт отправлен в канал алертов.')
766
+ }
767
+ const target = resolveAlertTarget(this)
768
+ const alertsCfg = this.tg().alerts || {}
769
+ return reply([
770
+ '🔔 <b>Канал алертов:</b>',
771
+ `Статус: ${alertsCfg.enabled ? 'включен' : 'выключен'}`,
772
+ `Чат: ${target ? `${target.chatId}${target.threadId ? ` (topic: ${target.threadId})` : ''}` : '(не назначен)'}`,
773
+ `События: ${(alertsCfg.events || ['error', 'pairing']).join(', ')}`,
774
+ '',
775
+ 'Команды:',
776
+ '/setalert — назначить текущий чат каналом алертов',
777
+ '/alert test — отправить тестовый алерт',
778
+ ].join('\n'))
779
+ }
780
+ if (cmd === '/remind') {
781
+ const sub = parts[1]?.toLowerCase()
782
+ if (sub === 'list') {
783
+ const active = await this.scheduler.list(chatId)
784
+ if (!active.length) return reply('Нет активных напоминаний для этого чата.')
785
+ const lines = [
786
+ '⏰ <b>Активные напоминания:</b>',
787
+ '',
788
+ ...active.map((t) => {
789
+ const left = formatRemaining(t.dueAt - Date.now())
790
+ return `• <code>${t.id}</code> (через ${left}): ${t.text}`
791
+ }),
792
+ '',
793
+ 'Отмена: <code>/remind cancel ID</code>',
794
+ ]
795
+ return reply(lines.join('\n'))
796
+ }
797
+ if (sub === 'cancel') {
798
+ const targetId = parts[2]?.trim()
799
+ if (!targetId) return reply('Укажите ID напоминания: <code>/remind cancel ID</code>')
800
+ const ok = await this.scheduler.cancel(targetId, chatId)
801
+ return reply(ok ? `✅ Напоминание <code>${targetId}</code> отменено.` : `❌ Напоминание с ID <code>${targetId}</code> не найдено.`)
802
+ }
803
+ const timeArg = parts[1]
804
+ const textArg = parts.slice(2).join(' ').trim()
805
+ const delayMs = parseRelativeTime(timeArg)
806
+ if (!delayMs || !textArg) {
807
+ return reply([
808
+ '⏰ <b>Напоминания:</b>',
809
+ 'Создать: <code>/remind &lt;время&gt; &lt;текст&gt;</code>',
810
+ 'Примеры: <code>/remind 10m Позвонить</code>, <code>/remind 2h Проверить деплой</code>',
811
+ 'Список: <code>/remind list</code>',
812
+ 'Отмена: <code>/remind cancel ID</code>',
813
+ ].join('\n'))
814
+ }
815
+ const dueAt = Date.now() + delayMs
816
+ const task = await this.scheduler.schedule({
817
+ platform: platform || 'telegram',
818
+ chatId,
819
+ threadId: threadId || 0,
820
+ userId,
821
+ text: textArg,
822
+ dueAt,
823
+ })
824
+ const left = formatRemaining(delayMs)
825
+ return reply(`⏰ Напоминание установлено на <b>через ${left}</b> (ID: <code>${task.id}</code>):\n«${textArg}»`)
826
+ }
480
827
  if (cmd === '/voice') {
481
828
  const sub = String(parts[1] || 'status').toLowerCase()
482
829
  if (sub === 'on' || sub === 'off') {
@@ -574,9 +921,14 @@ export class Gateway {
574
921
  }
575
922
 
576
923
  async buildUserContent(input, signal) {
577
- const { text, attachments = [], replyText, steer } = input
924
+ const { text, attachments = [], replyText, steer, personaOverride } = input
578
925
  const parts = []
579
926
  parts.push(String(this.config.agent?.instructionPrefix || MESSENGER_RELAY_INSTRUCTION))
927
+ const activePersonaId = personaOverride || this.personas.get(input.chatId)
928
+ const activePersona = getPersona(activePersonaId)
929
+ if (activePersona?.instruction) {
930
+ parts.push(`[Persona: ${activePersona.name} (${activePersona.icon})]\n${activePersona.instruction}`)
931
+ }
580
932
  if (steer) parts.push('[Steer / дополнение к текущему ходу — учти вместе с предыдущим запросом, не начинай ответ заново с нуля]')
581
933
  if (replyText?.trim()) parts.push(`[Ответ на сообщение: ${replyText.trim()}]`)
582
934
  const blocks = []
@@ -606,7 +958,14 @@ export class Gateway {
606
958
  parts.push(`[Голосовое сообщение (dsh-voice недоступен: ${msg})]`)
607
959
  }
608
960
  } else if (att.kind === 'document' || att.kind === 'video' || att.kind === 'animation' || att.kind === 'sticker') {
609
- parts.push(formatInboundDocument(att))
961
+ let parsed = null
962
+ if (att.kind === 'document' && att.path) {
963
+ try {
964
+ const maxDocBytes = Number(this.config.media?.maxTextInjectBytes) || 100 * 1024
965
+ parsed = await parseDocument(att.path, { maxBytes: maxDocBytes })
966
+ } catch {}
967
+ }
968
+ parts.push(formatInboundDocument(att, parsed))
610
969
  } else {
611
970
  parts.push(`[Файл: ${att.path}${att.name ? ` (${att.name})` : ''}]`)
612
971
  }
@@ -688,16 +1047,28 @@ export class Gateway {
688
1047
  if (progress) try { await progress.remove() } catch {}
689
1048
  progress = null
690
1049
  if (typeof react === 'function') react('').catch?.(() => {})
691
- const answer = stripReasoningPreamble(stripImageUrls(collector.lastText || collector.streamText || collector.parts.join('\n\n')))
1050
+ const rawAnswer = stripReasoningPreamble(stripImageUrls(collector.lastText || collector.streamText || collector.parts.join('\n\n')))
1051
+ const processed = processDiagramsAndTables(rawAnswer, {
1052
+ artifactPreviews: this.tg().artifactPreviews !== false,
1053
+ })
1054
+ const answer = processed.text
692
1055
  if (collector.reason?.kind === 'error') {
693
1056
  const err = collector.reason.error
694
1057
  const msg = `Ошибка агента: ${err?.code || 'error'}: ${err?.message || 'unknown'}`
1058
+ this.sendAlert('error', {
1059
+ code: err?.code || 'AGENT_ERROR',
1060
+ message: err?.message || 'unknown',
1061
+ sessionId,
1062
+ chatId: input.chatId,
1063
+ threadId: input.threadId,
1064
+ }).catch(() => {})
695
1065
  if (stream) { await scheduler?.flush(); await stream.finalize(msg) }
696
1066
  else await reply(msg)
697
1067
  return
698
1068
  }
699
1069
  const files = await buildOutboundFiles(this.ctx, this.baseUrl(), collector, { signal, logger: this.ctx.logger })
700
- if (!answer && !files.length) {
1070
+ const allFiles = [...files, ...(processed.files || [])]
1071
+ if (!answer && !allFiles.length) {
701
1072
  if (stream) { await scheduler?.flush(); await stream.finalize('(нет ответа)') }
702
1073
  else await reply('(нет ответа)')
703
1074
  return
@@ -708,10 +1079,10 @@ export class Gateway {
708
1079
  await scheduler?.flush()
709
1080
  await stream.finalize(chunks[0] || '(нет ответа)')
710
1081
  for (let i = 1; i < chunks.length; i++) await reply({ text: chunks[i] })
711
- if (files.length) await reply({ files })
1082
+ if (allFiles.length) await reply({ files: allFiles })
712
1083
  } else {
713
1084
  for (let i = 0; i < chunks.length; i++) {
714
- await reply({ text: chunks[i] || undefined, files: i === 0 ? files : [] })
1085
+ await reply({ text: chunks[i] || undefined, files: i === 0 ? allFiles : [] })
715
1086
  }
716
1087
  }
717
1088
  const chatTtsPref = this.chatTts.get(chat.target?.chatId)
@@ -736,6 +1107,13 @@ export class Gateway {
736
1107
  }
737
1108
  } catch (err) {
738
1109
  if (!signal?.aborted) {
1110
+ this.sendAlert('error', {
1111
+ code: err?.code || 'EXCEPTION',
1112
+ message: err?.message || String(err),
1113
+ sessionId,
1114
+ chatId: input.chatId,
1115
+ threadId: input.threadId,
1116
+ }).catch(() => {})
739
1117
  try {
740
1118
  if (stream) await stream.finalize(`Сбой: ${err.message}`)
741
1119
  else await reply(`Сбой: ${err.message}`)
package/lib/index.js CHANGED
@@ -52,6 +52,8 @@ function publicConfig(cfg) {
52
52
  webhookSecretConfigured: Boolean(String(cfg.telegram.webhookSecret || '').trim()),
53
53
  botTokenConfigured: Boolean(String(cfg.telegram.botToken || '').trim()),
54
54
  voiceMode: cfg.telegram.voiceMode,
55
+ quickActions: cfg.telegram.quickActions !== false,
56
+ artifactPreviews: cfg.telegram.artifactPreviews !== false,
55
57
  notifyBridge: {
56
58
  enabled: Boolean(cfg.telegram.notifyBridge?.enabled),
57
59
  events: cfg.telegram.notifyBridge?.events || ['task_done', 'error'],
@@ -167,15 +169,27 @@ export function apply(ctx, config) {
167
169
  ctx.effect(() => ctx.tools.register(defineTool({
168
170
  name: 'messenger_ask',
169
171
  description:
170
- 'Ask the Telegram user a multiple-choice question with inline buttons and wait for their choice. '
171
- + 'Use for confirmations and short choices. Only works inside messenger-gateway sessions (msgw-*).',
172
+ 'Ask the Telegram user a single or multiple-choice question with inline buttons/checkboxes and wait for their choice. '
173
+ + 'Supports mode: "single" (default) or "multi" (checkboxes with Done/Cancel buttons). Only works inside messenger-gateway sessions (msgw-*).',
172
174
  parameters: {
173
175
  text: { type: 'string', required: true, description: 'Question text shown in Telegram.' },
174
176
  buttons: {
175
177
  type: 'array',
176
- required: true,
177
- description: 'Rows of buttons: [[{ id, text }, ...], ...]',
178
+ description: 'Rows of buttons: [[{ id, text }, ...], ...] (for single choice)',
178
179
  },
180
+ options: {
181
+ type: 'array',
182
+ description: 'List of options: [{ id, text, selected?: boolean }, ...] (for single or multi choice)',
183
+ },
184
+ mode: {
185
+ type: 'string',
186
+ description: '"single" for instant choice or "multi" for checkboxes form',
187
+ },
188
+ selected: {
189
+ type: 'array',
190
+ description: 'Initial selected option IDs for multi-select mode',
191
+ },
192
+ pageSize: { type: 'number', description: 'Number of options per page (default 6).' },
179
193
  timeoutMs: { type: 'number', description: 'Wait timeout ms (default 300000).' },
180
194
  },
181
195
  output: {
@@ -185,6 +199,7 @@ export function apply(ctx, config) {
185
199
  properties: {
186
200
  ok: { type: 'boolean' },
187
201
  buttonId: { type: 'string' },
202
+ selected: { type: 'array', items: { type: 'string' } },
188
203
  data: { type: 'string' },
189
204
  error: { type: 'string' },
190
205
  },
@@ -192,7 +207,7 @@ export function apply(ctx, config) {
192
207
  render: (_args, value) => [{
193
208
  type: 'text',
194
209
  text: value && value.ok
195
- ? `User chose: ${value.buttonId}`
210
+ ? (value.selected?.length ? `User selected: ${value.selected.join(', ')}` : `User chose: ${value.buttonId}`)
196
211
  : `messenger_ask failed: ${value && value.error ? value.error : 'unknown'}`,
197
212
  }],
198
213
  },
@@ -200,12 +215,15 @@ export function apply(ctx, config) {
200
215
  const gw = getGw()
201
216
  if (!gw) return { ok: false, error: 'gateway not running' }
202
217
  try {
203
- const buttons = args.buttons
204
218
  const result = await gw.messengerAskFromAgent(exec.agent, {
205
219
  text: String(args.text || ''),
206
- buttons,
220
+ buttons: args.buttons,
221
+ options: args.options,
222
+ mode: args.mode,
223
+ selected: args.selected,
224
+ pageSize: args.pageSize,
207
225
  }, Number(args.timeoutMs) || 300_000)
208
- return { ok: true, buttonId: result?.buttonId, data: result?.data }
226
+ return { ok: true, buttonId: result?.buttonId, selected: result?.selected, data: result?.data }
209
227
  } catch (err) {
210
228
  return { ok: false, error: err instanceof Error ? err.message : String(err) }
211
229
  }
@@ -277,6 +295,45 @@ export function apply(ctx, config) {
277
295
  },
278
296
  }), 'dsh-messenger-gateway: messenger schema')
279
297
 
298
+ ctx.effect(() => ctx.webServer.register({
299
+ kind: 'exact', path: '/dsh-messenger-gateway/events',
300
+ handler: async (req, res) => {
301
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
302
+ const gw = getGw()
303
+ if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not ready' })
304
+ let payload
305
+ try {
306
+ payload = JSON.parse((await readBody(req)).toString('utf8') || '{}')
307
+ } catch {
308
+ return writeJson(res, 400, { ok: false, error: 'invalid json' })
309
+ }
310
+
311
+ const expectedSecret = source().webhooks?.secret
312
+ if (expectedSecret) {
313
+ const authHeader = req.headers?.authorization || ''
314
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
315
+ const tokenHeader = req.headers?.['x-webhook-secret'] || ''
316
+ const provided = bearer || tokenHeader || payload.secret
317
+ if (provided !== expectedSecret) {
318
+ return writeJson(res, 401, { ok: false, error: 'unauthorized' })
319
+ }
320
+ }
321
+
322
+ const { target, text, home, chatId, threadId, platform = 'telegram', files } = payload
323
+ if (!text && (!files || !files.length)) {
324
+ return writeJson(res, 400, { ok: false, error: 'text or files required' })
325
+ }
326
+
327
+ const dest = target || { platform, chatId, threadId, home }
328
+ try {
329
+ const result = await gw.messengerSend(dest, { text, files })
330
+ writeJson(res, 200, { ok: true, sent: result })
331
+ } catch (err) {
332
+ writeJson(res, 500, { ok: false, error: err.message })
333
+ }
334
+ },
335
+ }), 'dsh-messenger-gateway: webhook events')
336
+
280
337
  ctx.effect(() => ctx.webServer.register({
281
338
  kind: 'exact', path: '/dsh-messenger-gateway/config',
282
339
  handler: async (req, res) => {