@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/README.md +29 -1
- package/README.ru.md +125 -0
- package/README.zh.md +76 -0
- package/lib/adapters/discord.js +129 -4
- package/lib/adapters/index.js +17 -1
- package/lib/adapters/slack.js +91 -0
- package/lib/adapters/telegram.js +27 -2
- package/lib/alerts.js +64 -0
- package/lib/artifacts.js +118 -0
- package/lib/ask.js +89 -0
- package/lib/client.js +12 -0
- package/lib/commands.js +12 -0
- package/lib/config.js +20 -1
- package/lib/documents.js +145 -3
- package/lib/file-manager.js +162 -0
- package/lib/gateway.js +391 -13
- package/lib/index.js +65 -8
- package/lib/messenger-api.js +19 -1
- package/lib/personas.js +98 -0
- package/lib/scheduler.js +135 -0
- package/lib/session-ops.js +95 -0
- package/package.json +1 -1
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
|
|
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, {
|
|
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
|
-
|
|
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 <время> <текст></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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 ?
|
|
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
|
-
+ '
|
|
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
|
-
|
|
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) => {
|